d8b1ab2216f31e5b75b901d01f0bbd6904c1e05f
[moodle.git] / analytics / classes / model.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Prediction model representation.
19  *
20  * @package   core_analytics
21  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_analytics;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Prediction model representation.
31  *
32  * @package   core_analytics
33  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
34  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 class model {
38     /**
39      * All as expected.
40      */
41     const OK = 0;
43     /**
44      * There was a problem.
45      */
46     const GENERAL_ERROR = 1;
48     /**
49      * No dataset to analyse.
50      */
51     const NO_DATASET = 2;
53     /**
54      * Model with low prediction accuracy.
55      */
56     const EVALUATE_LOW_SCORE = 4;
58     /**
59      * Not enough data to evaluate the model properly.
60      */
61     const EVALUATE_NOT_ENOUGH_DATA = 8;
63     /**
64      * Invalid analysable for the time splitting method.
65      */
66     const ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD = 4;
68     /**
69      * Invalid analysable for all time splitting methods.
70      */
71     const ANALYSABLE_STATUS_INVALID_FOR_RANGEPROCESSORS = 8;
73     /**
74      * Invalid analysable for the target
75      */
76     const ANALYSABLE_STATUS_INVALID_FOR_TARGET = 16;
78     /**
79      * Minimum score to consider a non-static prediction model as good.
80      */
81     const MIN_SCORE = 0.7;
83     /**
84      * Minimum prediction confidence (from 0 to 1) to accept a prediction as reliable enough.
85      */
86     const PREDICTION_MIN_SCORE = 0.6;
88     /**
89      * Maximum standard deviation between different evaluation repetitions to consider that evaluation results are stable.
90      */
91     const ACCEPTED_DEVIATION = 0.05;
93     /**
94      * Number of evaluation repetitions.
95      */
96     const EVALUATION_ITERATIONS = 10;
98     /**
99      * @var \stdClass
100      */
101     protected $model = null;
103     /**
104      * @var \core_analytics\local\analyser\base
105      */
106     protected $analyser = null;
108     /**
109      * @var \core_analytics\local\target\base
110      */
111     protected $target = null;
113     /**
114      * @var \core_analytics\local\indicator\base[]
115      */
116     protected $indicators = null;
118     /**
119      * Unique Model id created from site info and last model modification.
120      *
121      * @var string
122      */
123     protected $uniqueid = null;
125     /**
126      * Constructor.
127      *
128      * @param int|\stdClass $model
129      * @return void
130      */
131     public function __construct($model) {
132         global $DB;
134         if (is_scalar($model)) {
135             $model = $DB->get_record('analytics_models', array('id' => $model), '*', MUST_EXIST);
136             if (!$model) {
137                 throw new \moodle_exception('errorunexistingmodel', 'analytics', '', $model);
138             }
139         }
140         $this->model = $model;
141     }
143     /**
144      * Quick safety check to discard site models which required components are not available anymore.
145      *
146      * @return bool
147      */
148     public function is_available() {
149         $target = $this->get_target();
150         if (!$target) {
151             return false;
152         }
154         $classname = $target->get_analyser_class();
155         if (!class_exists($classname)) {
156             return false;
157         }
159         return true;
160     }
162     /**
163      * Returns the model id.
164      *
165      * @return int
166      */
167     public function get_id() {
168         return $this->model->id;
169     }
171     /**
172      * Returns a plain \stdClass with the model data.
173      *
174      * @return \stdClass
175      */
176     public function get_model_obj() {
177         return $this->model;
178     }
180     /**
181      * Returns the model target.
182      *
183      * @return \core_analytics\local\target\base
184      */
185     public function get_target() {
186         if ($this->target !== null) {
187             return $this->target;
188         }
189         $instance = \core_analytics\manager::get_target($this->model->target);
190         $this->target = $instance;
192         return $this->target;
193     }
195     /**
196      * Returns the model indicators.
197      *
198      * @return \core_analytics\local\indicator\base[]
199      */
200     public function get_indicators() {
201         if ($this->indicators !== null) {
202             return $this->indicators;
203         }
205         $fullclassnames = json_decode($this->model->indicators);
207         if (!is_array($fullclassnames)) {
208             throw new \coding_exception('Model ' . $this->model->id . ' indicators can not be read');
209         }
211         $this->indicators = array();
212         foreach ($fullclassnames as $fullclassname) {
213             $instance = \core_analytics\manager::get_indicator($fullclassname);
214             if ($instance) {
215                 $this->indicators[$fullclassname] = $instance;
216             } else {
217                 debugging('Can\'t load ' . $fullclassname . ' indicator', DEBUG_DEVELOPER);
218             }
219         }
221         return $this->indicators;
222     }
224     /**
225      * Returns the list of indicators that could potentially be used by the model target.
226      *
227      * It includes the indicators that are part of the model.
228      *
229      * @return \core_analytics\local\indicator\base[]
230      */
231     public function get_potential_indicators() {
233         $indicators = \core_analytics\manager::get_all_indicators();
235         if (empty($this->analyser)) {
236             $this->init_analyser(array('evaluation' => true));
237         }
239         foreach ($indicators as $classname => $indicator) {
240             if ($this->analyser->check_indicator_requirements($indicator) !== true) {
241                 unset($indicators[$classname]);
242             }
243         }
244         return $indicators;
245     }
247     /**
248      * Returns the model analyser (defined by the model target).
249      *
250      * @param array $options Default initialisation with no options.
251      * @return \core_analytics\local\analyser\base
252      */
253     public function get_analyser($options = array()) {
254         if ($this->analyser !== null) {
255             return $this->analyser;
256         }
258         $this->init_analyser($options);
260         return $this->analyser;
261     }
263     /**
264      * Initialises the model analyser.
265      *
266      * @throws \coding_exception
267      * @param array $options
268      * @return void
269      */
270     protected function init_analyser($options = array()) {
272         $target = $this->get_target();
273         $indicators = $this->get_indicators();
275         if (empty($target)) {
276             throw new \moodle_exception('errornotarget', 'analytics');
277         }
279         $timesplittings = array();
280         if (empty($options['notimesplitting'])) {
281             if (!empty($options['evaluation'])) {
282                 // The evaluation process will run using all available time splitting methods unless one is specified.
283                 if (!empty($options['timesplitting'])) {
284                     $timesplitting = \core_analytics\manager::get_time_splitting($options['timesplitting']);
285                     $timesplittings = array($timesplitting->get_id() => $timesplitting);
286                 } else {
287                     $timesplittings = \core_analytics\manager::get_enabled_time_splitting_methods();
288                 }
289             } else {
291                 if (empty($this->model->timesplitting)) {
292                     throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
293                 }
295                 // Returned as an array as all actions (evaluation, training and prediction) go through the same process.
296                 $timesplittings = array($this->model->timesplitting => $this->get_time_splitting());
297             }
299             if (empty($timesplittings)) {
300                 throw new \moodle_exception('errornotimesplittings', 'analytics');
301             }
302         }
304         if (!empty($options['evaluation'])) {
305             foreach ($timesplittings as $timesplitting) {
306                 $timesplitting->set_evaluating(true);
307             }
308         }
310         $classname = $target->get_analyser_class();
311         if (!class_exists($classname)) {
312             throw new \coding_exception($classname . ' class does not exists');
313         }
315         // Returns a \core_analytics\local\analyser\base class.
316         $this->analyser = new $classname($this->model->id, $target, $indicators, $timesplittings, $options);
317     }
319     /**
320      * Returns the model time splitting method.
321      *
322      * @return \core_analytics\local\time_splitting\base|false Returns false if no time splitting.
323      */
324     public function get_time_splitting() {
325         if (empty($this->model->timesplitting)) {
326             return false;
327         }
328         return \core_analytics\manager::get_time_splitting($this->model->timesplitting);
329     }
331     /**
332      * Creates a new model. Enables it if $timesplittingid is specified.
333      *
334      * @param \core_analytics\local\target\base $target
335      * @param \core_analytics\local\indicator\base[] $indicators
336      * @param string $timesplittingid The time splitting method id (its fully qualified class name)
337      * @return \core_analytics\model
338      */
339     public static function create(\core_analytics\local\target\base $target, array $indicators, $timesplittingid = false) {
340         global $USER, $DB;
342         \core_analytics\manager::check_can_manage_models();
344         $indicatorclasses = self::indicator_classes($indicators);
346         $now = time();
348         $modelobj = new \stdClass();
349         $modelobj->target = $target->get_id();
350         $modelobj->indicators = json_encode($indicatorclasses);
351         $modelobj->version = $now;
352         $modelobj->timecreated = $now;
353         $modelobj->timemodified = $now;
354         $modelobj->usermodified = $USER->id;
356         $id = $DB->insert_record('analytics_models', $modelobj);
358         // Get db defaults.
359         $modelobj = $DB->get_record('analytics_models', array('id' => $id), '*', MUST_EXIST);
361         $model = new static($modelobj);
363         if ($timesplittingid) {
364             $model->enable($timesplittingid);
365         }
367         if ($model->is_static()) {
368             $model->mark_as_trained();
369         }
371         return $model;
372     }
374     /**
375      * Does this model exist?
376      *
377      * If no indicators are provided it considers any model with the provided
378      * target a match.
379      *
380      * @param \core_analytics\local\target\base $target
381      * @param \core_analytics\local\indicator\base[]|false $indicators
382      * @return bool
383      */
384     public static function exists(\core_analytics\local\target\base $target, $indicators = false) {
385         global $DB;
387         $existingmodels = $DB->get_records('analytics_models', array('target' => $target->get_id()));
389         if (!$indicators && $existingmodels) {
390             return true;
391         }
393         $indicatorids = array_keys($indicators);
394         sort($indicatorids);
396         foreach ($existingmodels as $modelobj) {
397             $model = new \core_analytics\model($modelobj);
398             $modelindicatorids = array_keys($model->get_indicators());
399             sort($modelindicatorids);
401             if ($indicatorids === $modelindicatorids) {
402                 return true;
403             }
404         }
405         return false;
406     }
408     /**
409      * Updates the model.
410      *
411      * @param int|bool $enabled
412      * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
413      * @param string|false $timesplittingid False to respect current time splitting method
414      * @return void
415      */
416     public function update($enabled, $indicators = false, $timesplittingid = '') {
417         global $USER, $DB;
419         \core_analytics\manager::check_can_manage_models();
421         $now = time();
423         if ($indicators !== false) {
424             $indicatorclasses = self::indicator_classes($indicators);
425             $indicatorsstr = json_encode($indicatorclasses);
426         } else {
427             // Respect current value.
428             $indicatorsstr = $this->model->indicators;
429         }
431         if ($timesplittingid === false) {
432             // Respect current value.
433             $timesplittingid = $this->model->timesplitting;
434         }
436         if ($this->model->timesplitting !== $timesplittingid ||
437                 $this->model->indicators !== $indicatorsstr) {
438             // We update the version of the model so different time splittings are not mixed up.
439             $this->model->version = $now;
441             // Delete generated predictions.
442             $this->clear_model();
444             // Reset trained flag.
445             $this->model->trained = 0;
447         } else if ($this->model->enabled != $enabled) {
448             // We purge the cached contexts with insights as some will not be visible anymore.
449             $this->purge_insights_cache();
450         }
452         $this->model->enabled = intval($enabled);
453         $this->model->indicators = $indicatorsstr;
454         $this->model->timesplitting = $timesplittingid;
455         $this->model->timemodified = $now;
456         $this->model->usermodified = $USER->id;
458         $DB->update_record('analytics_models', $this->model);
460         // It needs to be reset (just in case, we may already used it).
461         $this->uniqueid = null;
462     }
464     /**
465      * Removes the model.
466      *
467      * @return void
468      */
469     public function delete() {
470         global $DB;
472         \core_analytics\manager::check_can_manage_models();
474         $this->clear_model();
475         $DB->delete_records('analytics_models', array('id' => $this->model->id));
476         $DB->delete_records('analytics_models_log', array('modelid' => $this->model->id));
477     }
479     /**
480      * Evaluates the model.
481      *
482      * This method gets the site contents (through the analyser) creates a .csv dataset
483      * with them and evaluates the model prediction accuracy multiple times using the
484      * machine learning backend. It returns an object where the model score is the average
485      * prediction accuracy of all executed evaluations.
486      *
487      * @param array $options
488      * @return \stdClass[]
489      */
490     public function evaluate($options = array()) {
492         \core_analytics\manager::check_can_manage_models();
494         if ($this->is_static()) {
495             $this->get_analyser()->add_log(get_string('noevaluationbasedassumptions', 'analytics'));
496             $result = new \stdClass();
497             $result->status = self::NO_DATASET;
498             return array($this->get_time_splitting()->get_id() => $result);
499         }
501         $options['evaluation'] = true;
502         $this->init_analyser($options);
504         if (empty($this->get_indicators())) {
505             throw new \moodle_exception('errornoindicators', 'analytics');
506         }
508         $this->heavy_duty_mode();
510         // Before get_labelled_data call so we get an early exception if it is not ready.
511         $predictor = \core_analytics\manager::get_predictions_processor();
513         $datasets = $this->get_analyser()->get_labelled_data();
515         // No datasets generated.
516         if (empty($datasets)) {
517             $result = new \stdClass();
518             $result->status = self::NO_DATASET;
519             $result->info = $this->get_analyser()->get_logs();
520             return array($result);
521         }
523         if (!PHPUNIT_TEST && CLI_SCRIPT) {
524             echo PHP_EOL . get_string('processingsitecontents', 'analytics') . PHP_EOL;
525         }
527         $results = array();
528         foreach ($datasets as $timesplittingid => $dataset) {
530             $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingid);
532             $result = new \stdClass();
534             $dashestimesplittingid = str_replace('\\', '', $timesplittingid);
535             $outputdir = $this->get_output_dir(array('evaluation', $dashestimesplittingid));
537             // Evaluate the dataset, the deviation we accept in the results depends on the amount of iterations.
538             if ($this->get_target()->is_linear()) {
539                 $predictorresult = $predictor->evaluate_regression($this->get_unique_id(), self::ACCEPTED_DEVIATION,
540                 self::EVALUATION_ITERATIONS, $dataset, $outputdir);
541             } else {
542                 $predictorresult = $predictor->evaluate_classification($this->get_unique_id(), self::ACCEPTED_DEVIATION,
543                 self::EVALUATION_ITERATIONS, $dataset, $outputdir);
544             }
546             $result->status = $predictorresult->status;
547             $result->info = $predictorresult->info;
549             if (isset($predictorresult->score)) {
550                 $result->score = $predictorresult->score;
551             } else {
552                 // Prediction processors may return an error, default to 0 score in that case.
553                 $result->score = 0;
554             }
556             $dir = false;
557             if (!empty($predictorresult->dir)) {
558                 $dir = $predictorresult->dir;
559             }
561             $result->logid = $this->log_result($timesplitting->get_id(), $result->score, $dir, $result->info);
563             $results[$timesplitting->get_id()] = $result;
564         }
566         return $results;
567     }
569     /**
570      * Trains the model using the site contents.
571      *
572      * This method prepares a dataset from the site contents (through the analyser)
573      * and passes it to the machine learning backends. Static models are skipped as
574      * they do not require training.
575      *
576      * @return \stdClass
577      */
578     public function train() {
580         \core_analytics\manager::check_can_manage_models();
582         if ($this->is_static()) {
583             $this->get_analyser()->add_log(get_string('notrainingbasedassumptions', 'analytics'));
584             $result = new \stdClass();
585             $result->status = self::OK;
586             return $result;
587         }
589         if (!$this->is_enabled() || empty($this->model->timesplitting)) {
590             throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
591         }
593         if (empty($this->get_indicators())) {
594             throw new \moodle_exception('errornoindicators', 'analytics');
595         }
597         $this->heavy_duty_mode();
599         // Before get_labelled_data call so we get an early exception if it is not writable.
600         $outputdir = $this->get_output_dir(array('execution'));
602         // Before get_labelled_data call so we get an early exception if it is not ready.
603         $predictor = \core_analytics\manager::get_predictions_processor();
605         $datasets = $this->get_analyser()->get_labelled_data();
607         // No training if no files have been provided.
608         if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
610             $result = new \stdClass();
611             $result->status = self::NO_DATASET;
612             $result->info = $this->get_analyser()->get_logs();
613             return $result;
614         }
615         $samplesfile = $datasets[$this->model->timesplitting];
617         // Train using the dataset.
618         if ($this->get_target()->is_linear()) {
619             $predictorresult = $predictor->train_regression($this->get_unique_id(), $samplesfile, $outputdir);
620         } else {
621             $predictorresult = $predictor->train_classification($this->get_unique_id(), $samplesfile, $outputdir);
622         }
624         $result = new \stdClass();
625         $result->status = $predictorresult->status;
626         $result->info = $predictorresult->info;
628         $this->flag_file_as_used($samplesfile, 'trained');
630         // Mark the model as trained if it wasn't.
631         if ($this->model->trained == false) {
632             $this->mark_as_trained();
633         }
635         return $result;
636     }
638     /**
639      * Get predictions from the site contents.
640      *
641      * It analyses the site contents (through analyser classes) looking for samples
642      * ready to receive predictions. It generates a dataset with all samples ready to
643      * get predictions and it passes it to the machine learning backends or to the
644      * targets based on assumptions to get the predictions.
645      *
646      * @return \stdClass
647      */
648     public function predict() {
649         global $DB;
651         \core_analytics\manager::check_can_manage_models();
653         if (!$this->is_enabled() || empty($this->model->timesplitting)) {
654             throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
655         }
657         if (empty($this->get_indicators())) {
658             throw new \moodle_exception('errornoindicators', 'analytics');
659         }
661         $this->heavy_duty_mode();
663         // Before get_unlabelled_data call so we get an early exception if it is not writable.
664         $outputdir = $this->get_output_dir(array('execution'));
666         // Before get_unlabelled_data call so we get an early exception if it is not ready.
667         if (!$this->is_static()) {
668             $predictor = \core_analytics\manager::get_predictions_processor();
669         }
671         $samplesdata = $this->get_analyser()->get_unlabelled_data();
673         // Get the prediction samples file.
674         if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
676             $result = new \stdClass();
677             $result->status = self::NO_DATASET;
678             $result->info = $this->get_analyser()->get_logs();
679             return $result;
680         }
681         $samplesfile = $samplesdata[$this->model->timesplitting];
683         // We need to throw an exception if we are trying to predict stuff that was already predicted.
684         $params = array('modelid' => $this->model->id, 'fileid' => $samplesfile->get_id(), 'action' => 'predicted');
685         if ($predicted = $DB->get_record('analytics_used_files', $params)) {
686             throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id());
687         }
689         $indicatorcalculations = \core_analytics\dataset_manager::get_structured_data($samplesfile);
691         // Prepare the results object.
692         $result = new \stdClass();
694         if ($this->is_static()) {
695             // Prediction based on assumptions.
696             $result->status = self::OK;
697             $result->info = [];
698             $result->predictions = $this->get_static_predictions($indicatorcalculations);
700         } else {
701             // Estimation and classification processes run on the machine learning backend side.
702             if ($this->get_target()->is_linear()) {
703                 $predictorresult = $predictor->estimate($this->get_unique_id(), $samplesfile, $outputdir);
704             } else {
705                 $predictorresult = $predictor->classify($this->get_unique_id(), $samplesfile, $outputdir);
706             }
707             $result->status = $predictorresult->status;
708             $result->info = $predictorresult->info;
709             $result->predictions = $this->format_predictor_predictions($predictorresult);
710         }
712         if ($result->predictions) {
713             $samplecontexts = $this->execute_prediction_callbacks($result->predictions, $indicatorcalculations);
714         }
716         if (!empty($samplecontexts) && $this->uses_insights()) {
717             $this->trigger_insights($samplecontexts);
718         }
720         $this->flag_file_as_used($samplesfile, 'predicted');
722         return $result;
723     }
725     /**
726      * Formats the predictor results.
727      *
728      * @param array $predictorresult
729      * @return array
730      */
731     private function format_predictor_predictions($predictorresult) {
733         $predictions = array();
734         if ($predictorresult->predictions) {
735             foreach ($predictorresult->predictions as $sampleinfo) {
737                 // We parse each prediction.
738                 switch (count($sampleinfo)) {
739                     case 1:
740                         // For whatever reason the predictions processor could not process this sample, we
741                         // skip it and do nothing with it.
742                         debugging($this->model->id . ' model predictions processor could not process the sample with id ' .
743                             $sampleinfo[0], DEBUG_DEVELOPER);
744                         continue;
745                     case 2:
746                         // Prediction processors that do not return a prediction score will have the maximum prediction
747                         // score.
748                         list($uniquesampleid, $prediction) = $sampleinfo;
749                         $predictionscore = 1;
750                         break;
751                     case 3:
752                         list($uniquesampleid, $prediction, $predictionscore) = $sampleinfo;
753                         break;
754                     default:
755                         break;
756                 }
757                 $predictiondata = (object)['prediction' => $prediction, 'predictionscore' => $predictionscore];
758                 $predictions[$uniquesampleid] = $predictiondata;
759             }
760         }
761         return $predictions;
762     }
764     /**
765      * Execute the prediction callbacks defined by the target.
766      *
767      * @param \stdClass[] $predictions
768      * @param array $indicatorcalculations
769      * @return array
770      */
771     protected function execute_prediction_callbacks($predictions, $indicatorcalculations) {
773         // Here we will store all predictions' contexts, this will be used to limit which users will see those predictions.
774         $samplecontexts = array();
776         foreach ($predictions as $uniquesampleid => $prediction) {
778             if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
780                 // The unique sample id contains both the sampleid and the rangeindex.
781                 list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
783                 // Store the predicted values.
784                 list($record, $samplecontext) = $this->prepare_prediction_record($sampleid, $rangeindex, $prediction->prediction,
785                     $prediction->predictionscore, json_encode($indicatorcalculations[$uniquesampleid]));
787                 // We will later bulk-insert them all.
788                 $records[$uniquesampleid] = $record;
790                 // Also store all samples context to later generate insights or whatever action the target wants to perform.
791                 $samplecontexts[$samplecontext->id] = $samplecontext;
793                 $this->get_target()->prediction_callback($this->model->id, $sampleid, $rangeindex, $samplecontext,
794                     $prediction->prediction, $prediction->predictionscore);
795             }
796         }
798         $this->save_predictions($records);
800         return $samplecontexts;
801     }
803     /**
804      * Generates insights and updates the cache.
805      *
806      * @param \context[] $samplecontexts
807      * @return void
808      */
809     protected function trigger_insights($samplecontexts) {
811         // Notify the target that all predictions have been processed.
812         $this->get_target()->generate_insight_notifications($this->model->id, $samplecontexts);
814         // Update cache.
815         $cache = \cache::make('core', 'contextwithinsights');
816         foreach ($samplecontexts as $context) {
817             $modelids = $cache->get($context->id);
818             if (!$modelids) {
819                 // The cache is empty, but we don't know if it is empty because there are no insights
820                 // in this context or because cache/s have been purged, we need to be conservative and
821                 // "pay" 1 db read to fill up the cache.
822                 $models = \core_analytics\manager::get_models_with_insights($context);
823                 $cache->set($context->id, array_keys($models));
824             } else if (!in_array($this->get_id(), $modelids)) {
825                 array_push($modelids, $this->get_id());
826                 $cache->set($context->id, $modelids);
827             }
828         }
829     }
831     /**
832      * Get predictions from a static model.
833      *
834      * @param array $indicatorcalculations
835      * @return \stdClass[]
836      */
837     protected function get_static_predictions(&$indicatorcalculations) {
839         // Group samples by analysable for \core_analytics\local\target::calculate.
840         $analysables = array();
841         // List all sampleids together.
842         $sampleids = array();
844         foreach ($indicatorcalculations as $uniquesampleid => $indicators) {
845             list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
847             $analysable = $this->get_analyser()->get_sample_analysable($sampleid);
848             $analysableclass = get_class($analysable);
849             if (empty($analysables[$analysableclass])) {
850                 $analysables[$analysableclass] = array();
851             }
852             if (empty($analysables[$analysableclass][$rangeindex])) {
853                 $analysables[$analysableclass][$rangeindex] = (object)[
854                     'analysable' => $analysable,
855                     'indicatorsdata' => array(),
856                     'sampleids' => array()
857                 ];
858             }
859             // Using the sampleid as a key so we can easily merge indicators data later.
860             $analysables[$analysableclass][$rangeindex]->indicatorsdata[$sampleid] = $indicators;
861             // We could use indicatorsdata keys but the amount of redundant data is not that big and leaves code below cleaner.
862             $analysables[$analysableclass][$rangeindex]->sampleids[$sampleid] = $sampleid;
864             // Accumulate sample ids to get all their associated data in 1 single db query (analyser::get_samples).
865             $sampleids[$sampleid] = $sampleid;
866         }
868         // Get all samples data.
869         list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
871         // Calculate the targets.
872         $predictions = array();
873         foreach ($analysables as $analysableclass => $rangedata) {
874             foreach ($rangedata as $rangeindex => $data) {
876                 // Attach samples data and calculated indicators data.
877                 $this->get_target()->clear_sample_data();
878                 $this->get_target()->add_sample_data($samplesdata);
879                 $this->get_target()->add_sample_data($data->indicatorsdata);
881                 // Append new elements (we can not get duplicates because sample-analysable relation is N-1).
882                 $range = $this->get_time_splitting()->get_range_by_index($rangeindex);
883                 $this->get_target()->filter_out_invalid_samples($data->sampleids, $data->analysable, false);
884                 $calculations = $this->get_target()->calculate($data->sampleids, $data->analysable, $range['start'], $range['end']);
886                 // Missing $indicatorcalculations values in $calculations are caused by is_valid_sample. We need to remove
887                 // these $uniquesampleid from $indicatorcalculations because otherwise they will be stored as calculated
888                 // by self::save_prediction.
889                 $indicatorcalculations = array_filter($indicatorcalculations, function($indicators, $uniquesampleid) use ($calculations) {
890                     list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
891                     if (!isset($calculations[$sampleid])) {
892                         return false;
893                     }
894                     return true;
895                 }, ARRAY_FILTER_USE_BOTH);
897                 foreach ($calculations as $sampleid => $value) {
899                     $uniquesampleid = $this->get_time_splitting()->append_rangeindex($sampleid, $rangeindex);
901                     // Null means that the target couldn't calculate the sample, we also remove them from $indicatorcalculations.
902                     if (is_null($calculations[$sampleid])) {
903                         unset($indicatorcalculations[$uniquesampleid]);
904                         continue;
905                     }
907                     // Even if static predictions are based on assumptions we flag them as 100% because they are 100%
908                     // true according to what the developer defined.
909                     $predictions[$uniquesampleid] = (object)['prediction' => $value, 'predictionscore' => 1];
910                 }
911             }
912         }
913         return $predictions;
914     }
916     /**
917      * Stores the prediction in the database.
918      *
919      * @param int $sampleid
920      * @param int $rangeindex
921      * @param int $prediction
922      * @param float $predictionscore
923      * @param string $calculations
924      * @return \context
925      */
926     protected function prepare_prediction_record($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) {
927         global $DB;
929         $context = $this->get_analyser()->sample_access_context($sampleid);
931         $record = new \stdClass();
932         $record->modelid = $this->model->id;
933         $record->contextid = $context->id;
934         $record->sampleid = $sampleid;
935         $record->rangeindex = $rangeindex;
936         $record->prediction = $prediction;
937         $record->predictionscore = $predictionscore;
938         $record->calculations = $calculations;
939         $record->timecreated = time();
941         return array($record, $context);
942     }
944     /**
945      * Save the prediction objects.
946      *
947      * @param \stdClass[] $records
948      */
949     protected function save_predictions($records) {
950         global $DB;
951         $DB->insert_records('analytics_predictions', $records);
952     }
954     /**
955      * Enabled the model using the provided time splitting method.
956      *
957      * @param string|false $timesplittingid False to respect the current time splitting method.
958      * @return void
959      */
960     public function enable($timesplittingid = false) {
961         global $DB;
963         \core_analytics\manager::check_can_manage_models();
965         $now = time();
967         if ($timesplittingid && $timesplittingid !== $this->model->timesplitting) {
969             if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) {
970                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
971             }
973             if (substr($timesplittingid, 0, 1) !== '\\') {
974                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
975             }
977             $this->model->timesplitting = $timesplittingid;
978             $this->model->version = $now;
979         }
981         // Purge pages with insights as this may change things.
982         if ($timesplittingid && $timesplittingid !== $this->model->timesplitting ||
983                 $this->model->enabled != 1) {
984             $this->purge_insights_cache();
985         }
987         $this->model->enabled = 1;
988         $this->model->timemodified = $now;
990         // We don't always update timemodified intentionally as we reserve it for target, indicators or timesplitting updates.
991         $DB->update_record('analytics_models', $this->model);
993         // It needs to be reset (just in case, we may already used it).
994         $this->uniqueid = null;
995     }
997     /**
998      * Is this a static model (as defined by the target)?.
999      *
1000      * Static models are based on assumptions instead of in machine learning
1001      * backends results.
1002      *
1003      * @return bool
1004      */
1005     public function is_static() {
1006         return (bool)$this->get_target()->based_on_assumptions();
1007     }
1009     /**
1010      * Is this model enabled?
1011      *
1012      * @return bool
1013      */
1014     public function is_enabled() {
1015         return (bool)$this->model->enabled;
1016     }
1018     /**
1019      * Is this model already trained?
1020      *
1021      * @return bool
1022      */
1023     public function is_trained() {
1024         // Models which targets are based on assumptions do not need training.
1025         return (bool)$this->model->trained || $this->is_static();
1026     }
1028     /**
1029      * Marks the model as trained
1030      *
1031      * @return void
1032      */
1033     public function mark_as_trained() {
1034         global $DB;
1036         \core_analytics\manager::check_can_manage_models();
1038         $this->model->trained = 1;
1039         $DB->update_record('analytics_models', $this->model);
1040     }
1042     /**
1043      * Get the contexts with predictions.
1044      *
1045      * @param bool $skiphidden Skip hidden predictions
1046      * @return \stdClass[]
1047      */
1048     public function get_predictions_contexts($skiphidden = true) {
1049         global $DB, $USER;
1051         $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
1052                   JOIN {context} ctx ON ctx.id = ap.contextid
1053                  WHERE ap.modelid = :modelid";
1054         $params = array('modelid' => $this->model->id);
1056         if ($skiphidden) {
1057             $sql .= " AND NOT EXISTS (
1058               SELECT 1
1059                 FROM {analytics_prediction_actions} apa
1060                WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
1061             )";
1062             $params['userid'] = $USER->id;
1063             $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
1064             $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
1065         }
1067         return $DB->get_records_sql($sql, $params);
1068     }
1070     /**
1071      * Has this model generated predictions?
1072      *
1073      * We don't check analytics_predictions table because targets have the ability to
1074      * ignore some predicted values, if that is the case predictions are not even stored
1075      * in db.
1076      *
1077      * @return bool
1078      */
1079     public function any_prediction_obtained() {
1080         global $DB;
1081         return $DB->record_exists('analytics_predict_samples',
1082             array('modelid' => $this->model->id, 'timesplitting' => $this->model->timesplitting));
1083     }
1085     /**
1086      * Whether this model generates insights or not (defined by the model's target).
1087      *
1088      * @return bool
1089      */
1090     public function uses_insights() {
1091         $target = $this->get_target();
1092         return $target::uses_insights();
1093     }
1095     /**
1096      * Whether predictions exist for this context.
1097      *
1098      * @param \context $context
1099      * @return bool
1100      */
1101     public function predictions_exist(\context $context) {
1102         global $DB;
1104         // Filters out previous predictions keeping only the last time range one.
1105         $select = "modelid = :modelid AND contextid = :contextid";
1106         $params = array('modelid' => $this->model->id, 'contextid' => $context->id);
1107         return $DB->record_exists_select('analytics_predictions', $select, $params);
1108     }
1110     /**
1111      * Gets the predictions for this context.
1112      *
1113      * @param \context $context
1114      * @param bool $skiphidden Skip hidden predictions
1115      * @param int $page The page of results to fetch. False for all results.
1116      * @param int $perpage The max number of results to fetch. Ignored if $page is false.
1117      * @return array($total, \core_analytics\prediction[])
1118      */
1119     public function get_predictions(\context $context, $skiphidden = true, $page = false, $perpage = 100) {
1120         global $DB, $USER;
1122         \core_analytics\manager::check_can_list_insights($context);
1124         // Filters out previous predictions keeping only the last time range one.
1125         $sql = "SELECT ap.*
1126                   FROM {analytics_predictions} ap
1127                   JOIN (
1128                     SELECT sampleid, max(rangeindex) AS rangeindex
1129                       FROM {analytics_predictions}
1130                      WHERE modelid = :modelidsubap and contextid = :contextidsubap
1131                     GROUP BY sampleid
1132                   ) apsub
1133                   ON ap.sampleid = apsub.sampleid AND ap.rangeindex = apsub.rangeindex
1134                 WHERE ap.modelid = :modelid and ap.contextid = :contextid";
1136         $params = array('modelid' => $this->model->id, 'contextid' => $context->id,
1137             'modelidsubap' => $this->model->id, 'contextidsubap' => $context->id);
1139         if ($skiphidden) {
1140             $sql .= " AND NOT EXISTS (
1141               SELECT 1
1142                 FROM {analytics_prediction_actions} apa
1143                WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
1144             )";
1145             $params['userid'] = $USER->id;
1146             $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
1147             $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
1148         }
1150         $sql .= " ORDER BY ap.timecreated DESC";
1151         if (!$predictions = $DB->get_records_sql($sql, $params)) {
1152             return array();
1153         }
1155         // Get predicted samples' ids.
1156         $sampleids = array_map(function($prediction) {
1157             return $prediction->sampleid;
1158         }, $predictions);
1160         list($unused, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
1162         $current = 0;
1164         if ($page !== false) {
1165             $offset = $page * $perpage;
1166             $limit = $offset + $perpage;
1167         }
1169         foreach ($predictions as $predictionid => $predictiondata) {
1171             $sampleid = $predictiondata->sampleid;
1173             // Filter out predictions which samples are not available anymore.
1174             if (empty($samplesdata[$sampleid])) {
1175                 unset($predictions[$predictionid]);
1176                 continue;
1177             }
1179             // Return paginated dataset - we cannot paginate in the DB because we post filter the list.
1180             if ($page === false || ($current >= $offset && $current < $limit)) {
1181                 // Replace \stdClass object by \core_analytics\prediction objects.
1182                 $prediction = new \core_analytics\prediction($predictiondata, $samplesdata[$sampleid]);
1183                 $predictions[$predictionid] = $prediction;
1184             } else {
1185                 unset($predictions[$predictionid]);
1186             }
1188             $current++;
1189         }
1191         return [$current, $predictions];
1192     }
1194     /**
1195      * Returns the sample data of a prediction.
1196      *
1197      * @param \stdClass $predictionobj
1198      * @return array
1199      */
1200     public function prediction_sample_data($predictionobj) {
1202         list($unused, $samplesdata) = $this->get_analyser()->get_samples(array($predictionobj->sampleid));
1204         if (empty($samplesdata[$predictionobj->sampleid])) {
1205             throw new \moodle_exception('errorsamplenotavailable', 'analytics');
1206         }
1208         return $samplesdata[$predictionobj->sampleid];
1209     }
1211     /**
1212      * Returns the description of a sample
1213      *
1214      * @param \core_analytics\prediction $prediction
1215      * @return array 2 elements: list(string, \renderable)
1216      */
1217     public function prediction_sample_description(\core_analytics\prediction $prediction) {
1218         return $this->get_analyser()->sample_description($prediction->get_prediction_data()->sampleid,
1219             $prediction->get_prediction_data()->contextid, $prediction->get_sample_data());
1220     }
1222     /**
1223      * Returns the output directory for prediction processors.
1224      *
1225      * Directory structure as follows:
1226      * - Evaluation runs:
1227      *   models/$model->id/$model->version/evaluation/$model->timesplitting
1228      * - Training  & prediction runs:
1229      *   models/$model->id/$model->version/execution
1230      *
1231      * @param array $subdirs
1232      * @return string
1233      */
1234     protected function get_output_dir($subdirs = array()) {
1235         global $CFG;
1237         $subdirstr = '';
1238         foreach ($subdirs as $subdir) {
1239             $subdirstr .= DIRECTORY_SEPARATOR . $subdir;
1240         }
1242         $outputdir = get_config('analytics', 'modeloutputdir');
1243         if (empty($outputdir)) {
1244             // Apply default value.
1245             $outputdir = rtrim($CFG->dataroot, '/') . DIRECTORY_SEPARATOR . 'models';
1246         }
1248         // Append model id and version + subdirs.
1249         $outputdir .= DIRECTORY_SEPARATOR . $this->model->id . DIRECTORY_SEPARATOR . $this->model->version . $subdirstr;
1251         make_writable_directory($outputdir);
1253         return $outputdir;
1254     }
1256     /**
1257      * Returns a unique id for this model.
1258      *
1259      * This id should be unique for this site.
1260      *
1261      * @return string
1262      */
1263     public function get_unique_id() {
1264         global $CFG;
1266         if (!is_null($this->uniqueid)) {
1267             return $this->uniqueid;
1268         }
1270         // Generate a unique id for this site, this model and this time splitting method, considering the last time
1271         // that the model target and indicators were updated.
1272         $ids = array($CFG->wwwroot, $CFG->prefix, $this->model->id, $this->model->version);
1273         $this->uniqueid = sha1(implode('$$', $ids));
1275         return $this->uniqueid;
1276     }
1278     /**
1279      * Exports the model data.
1280      *
1281      * @return \stdClass
1282      */
1283     public function export() {
1285         \core_analytics\manager::check_can_manage_models();
1287         $data = clone $this->model;
1288         $data->target = $this->get_target()->get_name();
1290         if ($timesplitting = $this->get_time_splitting()) {
1291             $data->timesplitting = $timesplitting->get_name();
1292         }
1294         $data->indicators = array();
1295         foreach ($this->get_indicators() as $indicator) {
1296             $data->indicators[] = $indicator->get_name();
1297         }
1298         return $data;
1299     }
1301     /**
1302      * Returns the model logs data.
1303      *
1304      * @param int $limitfrom
1305      * @param int $limitnum
1306      * @return \stdClass[]
1307      */
1308     public function get_logs($limitfrom = 0, $limitnum = 0) {
1309         global $DB;
1311         \core_analytics\manager::check_can_manage_models();
1313         return $DB->get_records('analytics_models_log', array('modelid' => $this->get_id()), 'timecreated DESC', '*',
1314             $limitfrom, $limitnum);
1315     }
1317     /**
1318      * Merges all training data files into one and returns it.
1319      *
1320      * @return \stored_file|false
1321      */
1322     public function get_training_data() {
1324         \core_analytics\manager::check_can_manage_models();
1326         $timesplittingid = $this->get_time_splitting()->get_id();
1327         return \core_analytics\dataset_manager::export_training_data($this->get_id(), $timesplittingid);
1328     }
1330     /**
1331      * Flag the provided file as used for training or prediction.
1332      *
1333      * @param \stored_file $file
1334      * @param string $action
1335      * @return void
1336      */
1337     protected function flag_file_as_used(\stored_file $file, $action) {
1338         global $DB;
1340         $usedfile = new \stdClass();
1341         $usedfile->modelid = $this->model->id;
1342         $usedfile->fileid = $file->get_id();
1343         $usedfile->action = $action;
1344         $usedfile->time = time();
1345         $DB->insert_record('analytics_used_files', $usedfile);
1346     }
1348     /**
1349      * Log the evaluation results in the database.
1350      *
1351      * @param string $timesplittingid
1352      * @param float $score
1353      * @param string $dir
1354      * @param array $info
1355      * @return int The inserted log id
1356      */
1357     protected function log_result($timesplittingid, $score, $dir = false, $info = false) {
1358         global $DB, $USER;
1360         $log = new \stdClass();
1361         $log->modelid = $this->get_id();
1362         $log->version = $this->model->version;
1363         $log->target = $this->model->target;
1364         $log->indicators = $this->model->indicators;
1365         $log->timesplitting = $timesplittingid;
1366         $log->dir = $dir;
1367         if ($info) {
1368             // Ensure it is not an associative array.
1369             $log->info = json_encode(array_values($info));
1370         }
1371         $log->score = $score;
1372         $log->timecreated = time();
1373         $log->usermodified = $USER->id;
1375         return $DB->insert_record('analytics_models_log', $log);
1376     }
1378     /**
1379      * Utility method to return indicator class names from a list of indicator objects
1380      *
1381      * @param \core_analytics\local\indicator\base[] $indicators
1382      * @return string[]
1383      */
1384     private static function indicator_classes($indicators) {
1386         // What we want to check and store are the indicator classes not the keys.
1387         $indicatorclasses = array();
1388         foreach ($indicators as $indicator) {
1389             if (!\core_analytics\manager::is_valid($indicator, '\core_analytics\local\indicator\base')) {
1390                 if (!is_object($indicator) && !is_scalar($indicator)) {
1391                     $indicator = strval($indicator);
1392                 } else if (is_object($indicator)) {
1393                     $indicator = '\\' . get_class($indicator);
1394                 }
1395                 throw new \moodle_exception('errorinvalidindicator', 'analytics', '', $indicator);
1396             }
1397             $indicatorclasses[] = $indicator->get_id();
1398         }
1400         return $indicatorclasses;
1401     }
1403     /**
1404      * Clears the model training and prediction data.
1405      *
1406      * Executed after updating model critical elements like the time splitting method
1407      * or the indicators.
1408      *
1409      * @return void
1410      */
1411     private function clear_model() {
1412         global $DB;
1414         $predictionids = $DB->get_fieldset_select('analytics_predictions', 'id', 'modelid = :modelid',
1415             array('modelid' => $this->get_id()));
1416         if ($predictionids) {
1417             list($sql, $params) = $DB->get_in_or_equal($predictionids);
1418             $DB->delete_records_select('analytics_prediction_actions', "predictionid $sql", $params);
1419         }
1421         $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id));
1422         $DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id));
1423         $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
1424         $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
1426         // Purge all generated files.
1427         \core_analytics\dataset_manager::clear_model_files($this->model->id);
1429         // We don't expect people to clear models regularly and the cost of filling the cache is
1430         // 1 db read per context.
1431         $this->purge_insights_cache();
1432     }
1434     /**
1435      * Purges the insights cache.
1436      */
1437     private function purge_insights_cache() {
1438         $cache = \cache::make('core', 'contextwithinsights');
1439         $cache->purge();
1440     }
1442     /**
1443      * Increases system memory and time limits.
1444      *
1445      * @return void
1446      */
1447     private function heavy_duty_mode() {
1448         if (ini_get('memory_limit') != -1) {
1449             raise_memory_limit(MEMORY_HUGE);
1450         }
1451         \core_php_time_limit::raise();
1452     }