MDL-59694 analytics: Track processed analysables
[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) {
439             // Delete generated predictions before changing the model version.
440             $this->clear_model();
442             // It needs to be reset as the version changes.
443             $this->uniqueid = null;
445             // We update the version of the model so different time splittings are not mixed up.
446             $this->model->version = $now;
448             // Reset trained flag.
449             if (!$this->is_static()) {
450                 $this->model->trained = 0;
451             }
453         } else if ($this->model->enabled != $enabled) {
454             // We purge the cached contexts with insights as some will not be visible anymore.
455             $this->purge_insights_cache();
456         }
458         $this->model->enabled = intval($enabled);
459         $this->model->indicators = $indicatorsstr;
460         $this->model->timesplitting = $timesplittingid;
461         $this->model->timemodified = $now;
462         $this->model->usermodified = $USER->id;
464         $DB->update_record('analytics_models', $this->model);
465     }
467     /**
468      * Removes the model.
469      *
470      * @return void
471      */
472     public function delete() {
473         global $DB;
475         \core_analytics\manager::check_can_manage_models();
477         $this->clear_model();
479         // Method self::clear_model is already clearing the current model version.
480         $predictor = \core_analytics\manager::get_predictions_processor();
481         $predictor->delete_output_dir($this->get_output_dir(array(), true));
483         $DB->delete_records('analytics_models', array('id' => $this->model->id));
484         $DB->delete_records('analytics_models_log', array('modelid' => $this->model->id));
485     }
487     /**
488      * Evaluates the model.
489      *
490      * This method gets the site contents (through the analyser) creates a .csv dataset
491      * with them and evaluates the model prediction accuracy multiple times using the
492      * machine learning backend. It returns an object where the model score is the average
493      * prediction accuracy of all executed evaluations.
494      *
495      * @param array $options
496      * @return \stdClass[]
497      */
498     public function evaluate($options = array()) {
500         \core_analytics\manager::check_can_manage_models();
502         if ($this->is_static()) {
503             $this->get_analyser()->add_log(get_string('noevaluationbasedassumptions', 'analytics'));
504             $result = new \stdClass();
505             $result->status = self::NO_DATASET;
506             return array($this->get_time_splitting()->get_id() => $result);
507         }
509         $options['evaluation'] = true;
510         $this->init_analyser($options);
512         if (empty($this->get_indicators())) {
513             throw new \moodle_exception('errornoindicators', 'analytics');
514         }
516         $this->heavy_duty_mode();
518         // Before get_labelled_data call so we get an early exception if it is not ready.
519         $predictor = \core_analytics\manager::get_predictions_processor();
521         $datasets = $this->get_analyser()->get_labelled_data();
523         // No datasets generated.
524         if (empty($datasets)) {
525             $result = new \stdClass();
526             $result->status = self::NO_DATASET;
527             $result->info = $this->get_analyser()->get_logs();
528             return array($result);
529         }
531         if (!PHPUNIT_TEST && CLI_SCRIPT) {
532             echo PHP_EOL . get_string('processingsitecontents', 'analytics') . PHP_EOL;
533         }
535         $results = array();
536         foreach ($datasets as $timesplittingid => $dataset) {
538             $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingid);
540             $result = new \stdClass();
542             $dashestimesplittingid = str_replace('\\', '', $timesplittingid);
543             $outputdir = $this->get_output_dir(array('evaluation', $dashestimesplittingid));
545             // Evaluate the dataset, the deviation we accept in the results depends on the amount of iterations.
546             if ($this->get_target()->is_linear()) {
547                 $predictorresult = $predictor->evaluate_regression($this->get_unique_id(), self::ACCEPTED_DEVIATION,
548                 self::EVALUATION_ITERATIONS, $dataset, $outputdir);
549             } else {
550                 $predictorresult = $predictor->evaluate_classification($this->get_unique_id(), self::ACCEPTED_DEVIATION,
551                 self::EVALUATION_ITERATIONS, $dataset, $outputdir);
552             }
554             $result->status = $predictorresult->status;
555             $result->info = $predictorresult->info;
557             if (isset($predictorresult->score)) {
558                 $result->score = $predictorresult->score;
559             } else {
560                 // Prediction processors may return an error, default to 0 score in that case.
561                 $result->score = 0;
562             }
564             $dir = false;
565             if (!empty($predictorresult->dir)) {
566                 $dir = $predictorresult->dir;
567             }
569             $result->logid = $this->log_result($timesplitting->get_id(), $result->score, $dir, $result->info);
571             $results[$timesplitting->get_id()] = $result;
572         }
574         return $results;
575     }
577     /**
578      * Trains the model using the site contents.
579      *
580      * This method prepares a dataset from the site contents (through the analyser)
581      * and passes it to the machine learning backends. Static models are skipped as
582      * they do not require training.
583      *
584      * @return \stdClass
585      */
586     public function train() {
588         \core_analytics\manager::check_can_manage_models();
590         if ($this->is_static()) {
591             $this->get_analyser()->add_log(get_string('notrainingbasedassumptions', 'analytics'));
592             $result = new \stdClass();
593             $result->status = self::OK;
594             return $result;
595         }
597         if (!$this->is_enabled() || empty($this->model->timesplitting)) {
598             throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
599         }
601         if (empty($this->get_indicators())) {
602             throw new \moodle_exception('errornoindicators', 'analytics');
603         }
605         $this->heavy_duty_mode();
607         // Before get_labelled_data call so we get an early exception if it is not writable.
608         $outputdir = $this->get_output_dir(array('execution'));
610         // Before get_labelled_data call so we get an early exception if it is not ready.
611         $predictor = \core_analytics\manager::get_predictions_processor();
613         $datasets = $this->get_analyser()->get_labelled_data();
615         // No training if no files have been provided.
616         if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
618             $result = new \stdClass();
619             $result->status = self::NO_DATASET;
620             $result->info = $this->get_analyser()->get_logs();
621             return $result;
622         }
623         $samplesfile = $datasets[$this->model->timesplitting];
625         // Train using the dataset.
626         if ($this->get_target()->is_linear()) {
627             $predictorresult = $predictor->train_regression($this->get_unique_id(), $samplesfile, $outputdir);
628         } else {
629             $predictorresult = $predictor->train_classification($this->get_unique_id(), $samplesfile, $outputdir);
630         }
632         $result = new \stdClass();
633         $result->status = $predictorresult->status;
634         $result->info = $predictorresult->info;
636         $this->flag_file_as_used($samplesfile, 'trained');
638         // Mark the model as trained if it wasn't.
639         if ($this->model->trained == false) {
640             $this->mark_as_trained();
641         }
643         return $result;
644     }
646     /**
647      * Get predictions from the site contents.
648      *
649      * It analyses the site contents (through analyser classes) looking for samples
650      * ready to receive predictions. It generates a dataset with all samples ready to
651      * get predictions and it passes it to the machine learning backends or to the
652      * targets based on assumptions to get the predictions.
653      *
654      * @return \stdClass
655      */
656     public function predict() {
657         global $DB;
659         \core_analytics\manager::check_can_manage_models();
661         if (!$this->is_enabled() || empty($this->model->timesplitting)) {
662             throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
663         }
665         if (empty($this->get_indicators())) {
666             throw new \moodle_exception('errornoindicators', 'analytics');
667         }
669         $this->heavy_duty_mode();
671         // Before get_unlabelled_data call so we get an early exception if it is not writable.
672         $outputdir = $this->get_output_dir(array('execution'));
674         // Before get_unlabelled_data call so we get an early exception if it is not ready.
675         if (!$this->is_static()) {
676             $predictor = \core_analytics\manager::get_predictions_processor();
677         }
679         $samplesdata = $this->get_analyser()->get_unlabelled_data();
681         // Get the prediction samples file.
682         if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
684             $result = new \stdClass();
685             $result->status = self::NO_DATASET;
686             $result->info = $this->get_analyser()->get_logs();
687             return $result;
688         }
689         $samplesfile = $samplesdata[$this->model->timesplitting];
691         // We need to throw an exception if we are trying to predict stuff that was already predicted.
692         $params = array('modelid' => $this->model->id, 'fileid' => $samplesfile->get_id(), 'action' => 'predicted');
693         if ($predicted = $DB->get_record('analytics_used_files', $params)) {
694             throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id());
695         }
697         $indicatorcalculations = \core_analytics\dataset_manager::get_structured_data($samplesfile);
699         // Prepare the results object.
700         $result = new \stdClass();
702         if ($this->is_static()) {
703             // Prediction based on assumptions.
704             $result->status = self::OK;
705             $result->info = [];
706             $result->predictions = $this->get_static_predictions($indicatorcalculations);
708         } else {
709             // Estimation and classification processes run on the machine learning backend side.
710             if ($this->get_target()->is_linear()) {
711                 $predictorresult = $predictor->estimate($this->get_unique_id(), $samplesfile, $outputdir);
712             } else {
713                 $predictorresult = $predictor->classify($this->get_unique_id(), $samplesfile, $outputdir);
714             }
715             $result->status = $predictorresult->status;
716             $result->info = $predictorresult->info;
717             $result->predictions = $this->format_predictor_predictions($predictorresult);
718         }
720         if ($result->predictions) {
721             $samplecontexts = $this->execute_prediction_callbacks($result->predictions, $indicatorcalculations);
722         }
724         if (!empty($samplecontexts) && $this->uses_insights()) {
725             $this->trigger_insights($samplecontexts);
726         }
728         $this->flag_file_as_used($samplesfile, 'predicted');
730         return $result;
731     }
733     /**
734      * Formats the predictor results.
735      *
736      * @param array $predictorresult
737      * @return array
738      */
739     private function format_predictor_predictions($predictorresult) {
741         $predictions = array();
742         if ($predictorresult->predictions) {
743             foreach ($predictorresult->predictions as $sampleinfo) {
745                 // We parse each prediction.
746                 switch (count($sampleinfo)) {
747                     case 1:
748                         // For whatever reason the predictions processor could not process this sample, we
749                         // skip it and do nothing with it.
750                         debugging($this->model->id . ' model predictions processor could not process the sample with id ' .
751                             $sampleinfo[0], DEBUG_DEVELOPER);
752                         continue;
753                     case 2:
754                         // Prediction processors that do not return a prediction score will have the maximum prediction
755                         // score.
756                         list($uniquesampleid, $prediction) = $sampleinfo;
757                         $predictionscore = 1;
758                         break;
759                     case 3:
760                         list($uniquesampleid, $prediction, $predictionscore) = $sampleinfo;
761                         break;
762                     default:
763                         break;
764                 }
765                 $predictiondata = (object)['prediction' => $prediction, 'predictionscore' => $predictionscore];
766                 $predictions[$uniquesampleid] = $predictiondata;
767             }
768         }
769         return $predictions;
770     }
772     /**
773      * Execute the prediction callbacks defined by the target.
774      *
775      * @param \stdClass[] $predictions
776      * @param array $indicatorcalculations
777      * @return array
778      */
779     protected function execute_prediction_callbacks($predictions, $indicatorcalculations) {
781         // Here we will store all predictions' contexts, this will be used to limit which users will see those predictions.
782         $samplecontexts = array();
784         foreach ($predictions as $uniquesampleid => $prediction) {
786             if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
788                 // The unique sample id contains both the sampleid and the rangeindex.
789                 list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
791                 // Store the predicted values.
792                 list($record, $samplecontext) = $this->prepare_prediction_record($sampleid, $rangeindex, $prediction->prediction,
793                     $prediction->predictionscore, json_encode($indicatorcalculations[$uniquesampleid]));
795                 // We will later bulk-insert them all.
796                 $records[$uniquesampleid] = $record;
798                 // Also store all samples context to later generate insights or whatever action the target wants to perform.
799                 $samplecontexts[$samplecontext->id] = $samplecontext;
801                 $this->get_target()->prediction_callback($this->model->id, $sampleid, $rangeindex, $samplecontext,
802                     $prediction->prediction, $prediction->predictionscore);
803             }
804         }
806         $this->save_predictions($records);
808         return $samplecontexts;
809     }
811     /**
812      * Generates insights and updates the cache.
813      *
814      * @param \context[] $samplecontexts
815      * @return void
816      */
817     protected function trigger_insights($samplecontexts) {
819         // Notify the target that all predictions have been processed.
820         $this->get_target()->generate_insight_notifications($this->model->id, $samplecontexts);
822         // Update cache.
823         $cache = \cache::make('core', 'contextwithinsights');
824         foreach ($samplecontexts as $context) {
825             $modelids = $cache->get($context->id);
826             if (!$modelids) {
827                 // The cache is empty, but we don't know if it is empty because there are no insights
828                 // in this context or because cache/s have been purged, we need to be conservative and
829                 // "pay" 1 db read to fill up the cache.
830                 $models = \core_analytics\manager::get_models_with_insights($context);
831                 $cache->set($context->id, array_keys($models));
832             } else if (!in_array($this->get_id(), $modelids)) {
833                 array_push($modelids, $this->get_id());
834                 $cache->set($context->id, $modelids);
835             }
836         }
837     }
839     /**
840      * Get predictions from a static model.
841      *
842      * @param array $indicatorcalculations
843      * @return \stdClass[]
844      */
845     protected function get_static_predictions(&$indicatorcalculations) {
847         // Group samples by analysable for \core_analytics\local\target::calculate.
848         $analysables = array();
849         // List all sampleids together.
850         $sampleids = array();
852         foreach ($indicatorcalculations as $uniquesampleid => $indicators) {
853             list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
855             $analysable = $this->get_analyser()->get_sample_analysable($sampleid);
856             $analysableclass = get_class($analysable);
857             if (empty($analysables[$analysableclass])) {
858                 $analysables[$analysableclass] = array();
859             }
860             if (empty($analysables[$analysableclass][$rangeindex])) {
861                 $analysables[$analysableclass][$rangeindex] = (object)[
862                     'analysable' => $analysable,
863                     'indicatorsdata' => array(),
864                     'sampleids' => array()
865                 ];
866             }
867             // Using the sampleid as a key so we can easily merge indicators data later.
868             $analysables[$analysableclass][$rangeindex]->indicatorsdata[$sampleid] = $indicators;
869             // We could use indicatorsdata keys but the amount of redundant data is not that big and leaves code below cleaner.
870             $analysables[$analysableclass][$rangeindex]->sampleids[$sampleid] = $sampleid;
872             // Accumulate sample ids to get all their associated data in 1 single db query (analyser::get_samples).
873             $sampleids[$sampleid] = $sampleid;
874         }
876         // Get all samples data.
877         list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
879         // Calculate the targets.
880         $predictions = array();
881         foreach ($analysables as $analysableclass => $rangedata) {
882             foreach ($rangedata as $rangeindex => $data) {
884                 // Attach samples data and calculated indicators data.
885                 $this->get_target()->clear_sample_data();
886                 $this->get_target()->add_sample_data($samplesdata);
887                 $this->get_target()->add_sample_data($data->indicatorsdata);
889                 // Append new elements (we can not get duplicates because sample-analysable relation is N-1).
890                 $range = $this->get_time_splitting()->get_range_by_index($rangeindex);
891                 $this->get_target()->filter_out_invalid_samples($data->sampleids, $data->analysable, false);
892                 $calculations = $this->get_target()->calculate($data->sampleids, $data->analysable, $range['start'], $range['end']);
894                 // Missing $indicatorcalculations values in $calculations are caused by is_valid_sample. We need to remove
895                 // these $uniquesampleid from $indicatorcalculations because otherwise they will be stored as calculated
896                 // by self::save_prediction.
897                 $indicatorcalculations = array_filter($indicatorcalculations, function($indicators, $uniquesampleid) use ($calculations) {
898                     list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
899                     if (!isset($calculations[$sampleid])) {
900                         return false;
901                     }
902                     return true;
903                 }, ARRAY_FILTER_USE_BOTH);
905                 foreach ($calculations as $sampleid => $value) {
907                     $uniquesampleid = $this->get_time_splitting()->append_rangeindex($sampleid, $rangeindex);
909                     // Null means that the target couldn't calculate the sample, we also remove them from $indicatorcalculations.
910                     if (is_null($calculations[$sampleid])) {
911                         unset($indicatorcalculations[$uniquesampleid]);
912                         continue;
913                     }
915                     // Even if static predictions are based on assumptions we flag them as 100% because they are 100%
916                     // true according to what the developer defined.
917                     $predictions[$uniquesampleid] = (object)['prediction' => $value, 'predictionscore' => 1];
918                 }
919             }
920         }
921         return $predictions;
922     }
924     /**
925      * Stores the prediction in the database.
926      *
927      * @param int $sampleid
928      * @param int $rangeindex
929      * @param int $prediction
930      * @param float $predictionscore
931      * @param string $calculations
932      * @return \context
933      */
934     protected function prepare_prediction_record($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) {
935         global $DB;
937         $context = $this->get_analyser()->sample_access_context($sampleid);
939         $record = new \stdClass();
940         $record->modelid = $this->model->id;
941         $record->contextid = $context->id;
942         $record->sampleid = $sampleid;
943         $record->rangeindex = $rangeindex;
944         $record->prediction = $prediction;
945         $record->predictionscore = $predictionscore;
946         $record->calculations = $calculations;
947         $record->timecreated = time();
949         return array($record, $context);
950     }
952     /**
953      * Save the prediction objects.
954      *
955      * @param \stdClass[] $records
956      */
957     protected function save_predictions($records) {
958         global $DB;
959         $DB->insert_records('analytics_predictions', $records);
960     }
962     /**
963      * Enabled the model using the provided time splitting method.
964      *
965      * @param string|false $timesplittingid False to respect the current time splitting method.
966      * @return void
967      */
968     public function enable($timesplittingid = false) {
969         global $DB;
971         \core_analytics\manager::check_can_manage_models();
973         $now = time();
975         if ($timesplittingid && $timesplittingid !== $this->model->timesplitting) {
977             if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) {
978                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
979             }
981             if (substr($timesplittingid, 0, 1) !== '\\') {
982                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
983             }
985             // Delete generated predictions before changing the model version.
986             $this->clear_model();
988             // It needs to be reset as the version changes.
989             $this->uniqueid = null;
991             $this->model->timesplitting = $timesplittingid;
992             $this->model->version = $now;
994             // Reset trained flag.
995             if (!$this->is_static()) {
996                 $this->model->trained = 0;
997             }
998         }
1000         // Purge pages with insights as this may change things.
1001         if ($this->model->enabled != 1) {
1002             $this->purge_insights_cache();
1003         }
1005         $this->model->enabled = 1;
1006         $this->model->timemodified = $now;
1008         // We don't always update timemodified intentionally as we reserve it for target, indicators or timesplitting updates.
1009         $DB->update_record('analytics_models', $this->model);
1010     }
1012     /**
1013      * Is this a static model (as defined by the target)?.
1014      *
1015      * Static models are based on assumptions instead of in machine learning
1016      * backends results.
1017      *
1018      * @return bool
1019      */
1020     public function is_static() {
1021         return (bool)$this->get_target()->based_on_assumptions();
1022     }
1024     /**
1025      * Is this model enabled?
1026      *
1027      * @return bool
1028      */
1029     public function is_enabled() {
1030         return (bool)$this->model->enabled;
1031     }
1033     /**
1034      * Is this model already trained?
1035      *
1036      * @return bool
1037      */
1038     public function is_trained() {
1039         // Models which targets are based on assumptions do not need training.
1040         return (bool)$this->model->trained || $this->is_static();
1041     }
1043     /**
1044      * Marks the model as trained
1045      *
1046      * @return void
1047      */
1048     public function mark_as_trained() {
1049         global $DB;
1051         \core_analytics\manager::check_can_manage_models();
1053         $this->model->trained = 1;
1054         $DB->update_record('analytics_models', $this->model);
1055     }
1057     /**
1058      * Get the contexts with predictions.
1059      *
1060      * @param bool $skiphidden Skip hidden predictions
1061      * @return \stdClass[]
1062      */
1063     public function get_predictions_contexts($skiphidden = true) {
1064         global $DB, $USER;
1066         $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
1067                   JOIN {context} ctx ON ctx.id = ap.contextid
1068                  WHERE ap.modelid = :modelid";
1069         $params = array('modelid' => $this->model->id);
1071         if ($skiphidden) {
1072             $sql .= " AND NOT EXISTS (
1073               SELECT 1
1074                 FROM {analytics_prediction_actions} apa
1075                WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
1076             )";
1077             $params['userid'] = $USER->id;
1078             $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
1079             $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
1080         }
1082         return $DB->get_records_sql($sql, $params);
1083     }
1085     /**
1086      * Has this model generated predictions?
1087      *
1088      * We don't check analytics_predictions table because targets have the ability to
1089      * ignore some predicted values, if that is the case predictions are not even stored
1090      * in db.
1091      *
1092      * @return bool
1093      */
1094     public function any_prediction_obtained() {
1095         global $DB;
1096         return $DB->record_exists('analytics_predict_samples',
1097             array('modelid' => $this->model->id, 'timesplitting' => $this->model->timesplitting));
1098     }
1100     /**
1101      * Whether this model generates insights or not (defined by the model's target).
1102      *
1103      * @return bool
1104      */
1105     public function uses_insights() {
1106         $target = $this->get_target();
1107         return $target::uses_insights();
1108     }
1110     /**
1111      * Whether predictions exist for this context.
1112      *
1113      * @param \context $context
1114      * @return bool
1115      */
1116     public function predictions_exist(\context $context) {
1117         global $DB;
1119         // Filters out previous predictions keeping only the last time range one.
1120         $select = "modelid = :modelid AND contextid = :contextid";
1121         $params = array('modelid' => $this->model->id, 'contextid' => $context->id);
1122         return $DB->record_exists_select('analytics_predictions', $select, $params);
1123     }
1125     /**
1126      * Gets the predictions for this context.
1127      *
1128      * @param \context $context
1129      * @param bool $skiphidden Skip hidden predictions
1130      * @param int $page The page of results to fetch. False for all results.
1131      * @param int $perpage The max number of results to fetch. Ignored if $page is false.
1132      * @return array($total, \core_analytics\prediction[])
1133      */
1134     public function get_predictions(\context $context, $skiphidden = true, $page = false, $perpage = 100) {
1135         global $DB, $USER;
1137         \core_analytics\manager::check_can_list_insights($context);
1139         // Filters out previous predictions keeping only the last time range one.
1140         $sql = "SELECT ap.*
1141                   FROM {analytics_predictions} ap
1142                   JOIN (
1143                     SELECT sampleid, max(rangeindex) AS rangeindex
1144                       FROM {analytics_predictions}
1145                      WHERE modelid = :modelidsubap and contextid = :contextidsubap
1146                     GROUP BY sampleid
1147                   ) apsub
1148                   ON ap.sampleid = apsub.sampleid AND ap.rangeindex = apsub.rangeindex
1149                 WHERE ap.modelid = :modelid and ap.contextid = :contextid";
1151         $params = array('modelid' => $this->model->id, 'contextid' => $context->id,
1152             'modelidsubap' => $this->model->id, 'contextidsubap' => $context->id);
1154         if ($skiphidden) {
1155             $sql .= " AND NOT EXISTS (
1156               SELECT 1
1157                 FROM {analytics_prediction_actions} apa
1158                WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
1159             )";
1160             $params['userid'] = $USER->id;
1161             $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
1162             $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
1163         }
1165         $sql .= " ORDER BY ap.timecreated DESC";
1166         if (!$predictions = $DB->get_records_sql($sql, $params)) {
1167             return array();
1168         }
1170         // Get predicted samples' ids.
1171         $sampleids = array_map(function($prediction) {
1172             return $prediction->sampleid;
1173         }, $predictions);
1175         list($unused, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
1177         $current = 0;
1179         if ($page !== false) {
1180             $offset = $page * $perpage;
1181             $limit = $offset + $perpage;
1182         }
1184         foreach ($predictions as $predictionid => $predictiondata) {
1186             $sampleid = $predictiondata->sampleid;
1188             // Filter out predictions which samples are not available anymore.
1189             if (empty($samplesdata[$sampleid])) {
1190                 unset($predictions[$predictionid]);
1191                 continue;
1192             }
1194             // Return paginated dataset - we cannot paginate in the DB because we post filter the list.
1195             if ($page === false || ($current >= $offset && $current < $limit)) {
1196                 // Replace \stdClass object by \core_analytics\prediction objects.
1197                 $prediction = new \core_analytics\prediction($predictiondata, $samplesdata[$sampleid]);
1198                 $predictions[$predictionid] = $prediction;
1199             } else {
1200                 unset($predictions[$predictionid]);
1201             }
1203             $current++;
1204         }
1206         return [$current, $predictions];
1207     }
1209     /**
1210      * Returns the sample data of a prediction.
1211      *
1212      * @param \stdClass $predictionobj
1213      * @return array
1214      */
1215     public function prediction_sample_data($predictionobj) {
1217         list($unused, $samplesdata) = $this->get_analyser()->get_samples(array($predictionobj->sampleid));
1219         if (empty($samplesdata[$predictionobj->sampleid])) {
1220             throw new \moodle_exception('errorsamplenotavailable', 'analytics');
1221         }
1223         return $samplesdata[$predictionobj->sampleid];
1224     }
1226     /**
1227      * Returns the description of a sample
1228      *
1229      * @param \core_analytics\prediction $prediction
1230      * @return array 2 elements: list(string, \renderable)
1231      */
1232     public function prediction_sample_description(\core_analytics\prediction $prediction) {
1233         return $this->get_analyser()->sample_description($prediction->get_prediction_data()->sampleid,
1234             $prediction->get_prediction_data()->contextid, $prediction->get_sample_data());
1235     }
1237     /**
1238      * Returns the output directory for prediction processors.
1239      *
1240      * Directory structure as follows:
1241      * - Evaluation runs:
1242      *   models/$model->id/$model->version/evaluation/$model->timesplitting
1243      * - Training  & prediction runs:
1244      *   models/$model->id/$model->version/execution
1245      *
1246      * @param array $subdirs
1247      * @param bool $onlymodelid Preference over $subdirs
1248      * @return string
1249      */
1250     protected function get_output_dir($subdirs = array(), $onlymodelid = false) {
1251         global $CFG;
1253         $subdirstr = '';
1254         foreach ($subdirs as $subdir) {
1255             $subdirstr .= DIRECTORY_SEPARATOR . $subdir;
1256         }
1258         $outputdir = get_config('analytics', 'modeloutputdir');
1259         if (empty($outputdir)) {
1260             // Apply default value.
1261             $outputdir = rtrim($CFG->dataroot, '/') . DIRECTORY_SEPARATOR . 'models';
1262         }
1264         // Append model id
1265         $outputdir .= DIRECTORY_SEPARATOR . $this->model->id;
1266         if (!$onlymodelid) {
1267             // Append version + subdirs.
1268             $outputdir .= DIRECTORY_SEPARATOR . $this->model->version . $subdirstr;
1269         }
1271         make_writable_directory($outputdir);
1273         return $outputdir;
1274     }
1276     /**
1277      * Returns a unique id for this model.
1278      *
1279      * This id should be unique for this site.
1280      *
1281      * @return string
1282      */
1283     public function get_unique_id() {
1284         global $CFG;
1286         if (!is_null($this->uniqueid)) {
1287             return $this->uniqueid;
1288         }
1290         // Generate a unique id for this site, this model and this time splitting method, considering the last time
1291         // that the model target and indicators were updated.
1292         $ids = array($CFG->wwwroot, $CFG->prefix, $this->model->id, $this->model->version);
1293         $this->uniqueid = sha1(implode('$$', $ids));
1295         return $this->uniqueid;
1296     }
1298     /**
1299      * Exports the model data.
1300      *
1301      * @return \stdClass
1302      */
1303     public function export() {
1305         \core_analytics\manager::check_can_manage_models();
1307         $data = clone $this->model;
1308         $data->target = $this->get_target()->get_name();
1310         if ($timesplitting = $this->get_time_splitting()) {
1311             $data->timesplitting = $timesplitting->get_name();
1312         }
1314         $data->indicators = array();
1315         foreach ($this->get_indicators() as $indicator) {
1316             $data->indicators[] = $indicator->get_name();
1317         }
1318         return $data;
1319     }
1321     /**
1322      * Returns the model logs data.
1323      *
1324      * @param int $limitfrom
1325      * @param int $limitnum
1326      * @return \stdClass[]
1327      */
1328     public function get_logs($limitfrom = 0, $limitnum = 0) {
1329         global $DB;
1331         \core_analytics\manager::check_can_manage_models();
1333         return $DB->get_records('analytics_models_log', array('modelid' => $this->get_id()), 'timecreated DESC', '*',
1334             $limitfrom, $limitnum);
1335     }
1337     /**
1338      * Merges all training data files into one and returns it.
1339      *
1340      * @return \stored_file|false
1341      */
1342     public function get_training_data() {
1344         \core_analytics\manager::check_can_manage_models();
1346         $timesplittingid = $this->get_time_splitting()->get_id();
1347         return \core_analytics\dataset_manager::export_training_data($this->get_id(), $timesplittingid);
1348     }
1350     /**
1351      * Flag the provided file as used for training or prediction.
1352      *
1353      * @param \stored_file $file
1354      * @param string $action
1355      * @return void
1356      */
1357     protected function flag_file_as_used(\stored_file $file, $action) {
1358         global $DB;
1360         $usedfile = new \stdClass();
1361         $usedfile->modelid = $this->model->id;
1362         $usedfile->fileid = $file->get_id();
1363         $usedfile->action = $action;
1364         $usedfile->time = time();
1365         $DB->insert_record('analytics_used_files', $usedfile);
1366     }
1368     /**
1369      * Log the evaluation results in the database.
1370      *
1371      * @param string $timesplittingid
1372      * @param float $score
1373      * @param string $dir
1374      * @param array $info
1375      * @return int The inserted log id
1376      */
1377     protected function log_result($timesplittingid, $score, $dir = false, $info = false) {
1378         global $DB, $USER;
1380         $log = new \stdClass();
1381         $log->modelid = $this->get_id();
1382         $log->version = $this->model->version;
1383         $log->target = $this->model->target;
1384         $log->indicators = $this->model->indicators;
1385         $log->timesplitting = $timesplittingid;
1386         $log->dir = $dir;
1387         if ($info) {
1388             // Ensure it is not an associative array.
1389             $log->info = json_encode(array_values($info));
1390         }
1391         $log->score = $score;
1392         $log->timecreated = time();
1393         $log->usermodified = $USER->id;
1395         return $DB->insert_record('analytics_models_log', $log);
1396     }
1398     /**
1399      * Utility method to return indicator class names from a list of indicator objects
1400      *
1401      * @param \core_analytics\local\indicator\base[] $indicators
1402      * @return string[]
1403      */
1404     private static function indicator_classes($indicators) {
1406         // What we want to check and store are the indicator classes not the keys.
1407         $indicatorclasses = array();
1408         foreach ($indicators as $indicator) {
1409             if (!\core_analytics\manager::is_valid($indicator, '\core_analytics\local\indicator\base')) {
1410                 if (!is_object($indicator) && !is_scalar($indicator)) {
1411                     $indicator = strval($indicator);
1412                 } else if (is_object($indicator)) {
1413                     $indicator = '\\' . get_class($indicator);
1414                 }
1415                 throw new \moodle_exception('errorinvalidindicator', 'analytics', '', $indicator);
1416             }
1417             $indicatorclasses[] = $indicator->get_id();
1418         }
1420         return $indicatorclasses;
1421     }
1423     /**
1424      * Clears the model training and prediction data.
1425      *
1426      * Executed after updating model critical elements like the time splitting method
1427      * or the indicators.
1428      *
1429      * @return void
1430      */
1431     private function clear_model() {
1432         global $DB;
1434         // Delete current model version stored stuff.
1435         $predictor = \core_analytics\manager::get_predictions_processor();
1436         $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
1438         $predictionids = $DB->get_fieldset_select('analytics_predictions', 'id', 'modelid = :modelid',
1439             array('modelid' => $this->get_id()));
1440         if ($predictionids) {
1441             list($sql, $params) = $DB->get_in_or_equal($predictionids);
1442             $DB->delete_records_select('analytics_prediction_actions', "predictionid $sql", $params);
1443         }
1445         $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id));
1446         $DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id));
1447         $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
1448         $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
1449         $DB->delete_records('analytics_used_analysables', array('modelid' => $this->model->id));
1451         // Purge all generated files.
1452         \core_analytics\dataset_manager::clear_model_files($this->model->id);
1454         // We don't expect people to clear models regularly and the cost of filling the cache is
1455         // 1 db read per context.
1456         $this->purge_insights_cache();
1457     }
1459     /**
1460      * Purges the insights cache.
1461      */
1462     private function purge_insights_cache() {
1463         $cache = \cache::make('core', 'contextwithinsights');
1464         $cache->purge();
1465     }
1467     /**
1468      * Increases system memory and time limits.
1469      *
1470      * @return void
1471      */
1472     private function heavy_duty_mode() {
1473         if (ini_get('memory_limit') != -1) {
1474             raise_memory_limit(MEMORY_HUGE);
1475         }
1476         \core_php_time_limit::raise();
1477     }