MDL-61667 analytics: Remove duplicated capability checks
[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 LOW_SCORE = 4;
58     /**
59      * Not enough data to evaluate the model properly.
60      */
61     const 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\predictor
115      */
116     protected $predictionsprocessor = null;
118     /**
119      * @var \core_analytics\local\indicator\base[]
120      */
121     protected $indicators = null;
123     /**
124      * Unique Model id created from site info and last model modification.
125      *
126      * @var string
127      */
128     protected $uniqueid = null;
130     /**
131      * Constructor.
132      *
133      * @param int|\stdClass $model
134      * @return void
135      */
136     public function __construct($model) {
137         global $DB;
139         if (is_scalar($model)) {
140             $model = $DB->get_record('analytics_models', array('id' => $model), '*', MUST_EXIST);
141             if (!$model) {
142                 throw new \moodle_exception('errorunexistingmodel', 'analytics', '', $model);
143             }
144         }
145         $this->model = $model;
146     }
148     /**
149      * Quick safety check to discard site models which required components are not available anymore.
150      *
151      * @return bool
152      */
153     public function is_available() {
154         $target = $this->get_target();
155         if (!$target) {
156             return false;
157         }
159         $classname = $target->get_analyser_class();
160         if (!class_exists($classname)) {
161             return false;
162         }
164         return true;
165     }
167     /**
168      * Returns the model id.
169      *
170      * @return int
171      */
172     public function get_id() {
173         return $this->model->id;
174     }
176     /**
177      * Returns a plain \stdClass with the model data.
178      *
179      * @return \stdClass
180      */
181     public function get_model_obj() {
182         return $this->model;
183     }
185     /**
186      * Returns the model target.
187      *
188      * @return \core_analytics\local\target\base
189      */
190     public function get_target() {
191         if ($this->target !== null) {
192             return $this->target;
193         }
194         $instance = \core_analytics\manager::get_target($this->model->target);
195         $this->target = $instance;
197         return $this->target;
198     }
200     /**
201      * Returns the model indicators.
202      *
203      * @return \core_analytics\local\indicator\base[]
204      */
205     public function get_indicators() {
206         if ($this->indicators !== null) {
207             return $this->indicators;
208         }
210         $fullclassnames = json_decode($this->model->indicators);
212         if (!is_array($fullclassnames)) {
213             throw new \coding_exception('Model ' . $this->model->id . ' indicators can not be read');
214         }
216         $this->indicators = array();
217         foreach ($fullclassnames as $fullclassname) {
218             $instance = \core_analytics\manager::get_indicator($fullclassname);
219             if ($instance) {
220                 $this->indicators[$fullclassname] = $instance;
221             } else {
222                 debugging('Can\'t load ' . $fullclassname . ' indicator', DEBUG_DEVELOPER);
223             }
224         }
226         return $this->indicators;
227     }
229     /**
230      * Returns the list of indicators that could potentially be used by the model target.
231      *
232      * It includes the indicators that are part of the model.
233      *
234      * @return \core_analytics\local\indicator\base[]
235      */
236     public function get_potential_indicators() {
238         $indicators = \core_analytics\manager::get_all_indicators();
240         if (empty($this->analyser)) {
241             $this->init_analyser(array('evaluation' => true));
242         }
244         foreach ($indicators as $classname => $indicator) {
245             if ($this->analyser->check_indicator_requirements($indicator) !== true) {
246                 unset($indicators[$classname]);
247             }
248         }
249         return $indicators;
250     }
252     /**
253      * Returns the model analyser (defined by the model target).
254      *
255      * @param array $options Default initialisation with no options.
256      * @return \core_analytics\local\analyser\base
257      */
258     public function get_analyser($options = array()) {
259         if ($this->analyser !== null) {
260             return $this->analyser;
261         }
263         $this->init_analyser($options);
265         return $this->analyser;
266     }
268     /**
269      * Initialises the model analyser.
270      *
271      * @throws \coding_exception
272      * @param array $options
273      * @return void
274      */
275     protected function init_analyser($options = array()) {
277         $target = $this->get_target();
278         $indicators = $this->get_indicators();
280         if (empty($target)) {
281             throw new \moodle_exception('errornotarget', 'analytics');
282         }
284         $timesplittings = array();
285         if (empty($options['notimesplitting'])) {
286             if (!empty($options['evaluation'])) {
287                 // The evaluation process will run using all available time splitting methods unless one is specified.
288                 if (!empty($options['timesplitting'])) {
289                     $timesplitting = \core_analytics\manager::get_time_splitting($options['timesplitting']);
290                     $timesplittings = array($timesplitting->get_id() => $timesplitting);
291                 } else {
292                     $timesplittings = \core_analytics\manager::get_time_splitting_methods_for_evaluation();
293                 }
294             } else {
296                 if (empty($this->model->timesplitting)) {
297                     throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
298                 }
300                 // Returned as an array as all actions (evaluation, training and prediction) go through the same process.
301                 $timesplittings = array($this->model->timesplitting => $this->get_time_splitting());
302             }
304             if (empty($timesplittings)) {
305                 throw new \moodle_exception('errornotimesplittings', 'analytics');
306             }
307         }
309         if (!empty($options['evaluation'])) {
310             foreach ($timesplittings as $timesplitting) {
311                 $timesplitting->set_evaluating(true);
312             }
313         }
315         $classname = $target->get_analyser_class();
316         if (!class_exists($classname)) {
317             throw new \coding_exception($classname . ' class does not exists');
318         }
320         // Returns a \core_analytics\local\analyser\base class.
321         $this->analyser = new $classname($this->model->id, $target, $indicators, $timesplittings, $options);
322     }
324     /**
325      * Returns the model time splitting method.
326      *
327      * @return \core_analytics\local\time_splitting\base|false Returns false if no time splitting.
328      */
329     public function get_time_splitting() {
330         if (empty($this->model->timesplitting)) {
331             return false;
332         }
333         return \core_analytics\manager::get_time_splitting($this->model->timesplitting);
334     }
336     /**
337      * Creates a new model. Enables it if $timesplittingid is specified.
338      *
339      * @param \core_analytics\local\target\base $target
340      * @param \core_analytics\local\indicator\base[] $indicators
341      * @param string|false $timesplittingid The time splitting method id (its fully qualified class name)
342      * @param string|null $processor The machine learning backend this model will use.
343      * @return \core_analytics\model
344      */
345     public static function create(\core_analytics\local\target\base $target, array $indicators,
346                                   $timesplittingid = false, $processor = null) {
347         global $USER, $DB;
349         $indicatorclasses = self::indicator_classes($indicators);
351         $now = time();
353         $modelobj = new \stdClass();
354         $modelobj->target = $target->get_id();
355         $modelobj->indicators = json_encode($indicatorclasses);
356         $modelobj->version = $now;
357         $modelobj->timecreated = $now;
358         $modelobj->timemodified = $now;
359         $modelobj->usermodified = $USER->id;
361         if ($target->based_on_assumptions()) {
362             $modelobj->trained = 1;
363         }
365         if ($timesplittingid) {
366             if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) {
367                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
368             }
369             if (substr($timesplittingid, 0, 1) !== '\\') {
370                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
371             }
372             $modelobj->timesplitting = $timesplittingid;
373         }
375         if ($processor &&
376                 !manager::is_valid($processor, '\core_analytics\classifier') &&
377                 !manager::is_valid($processor, '\core_analytics\regressor')) {
378             throw new \coding_exception('The provided predictions processor \\' . $processor . '\processor is not valid');
379         } else {
380             $modelobj->predictionsprocessor = $processor;
381         }
383         $id = $DB->insert_record('analytics_models', $modelobj);
385         // Get db defaults.
386         $modelobj = $DB->get_record('analytics_models', array('id' => $id), '*', MUST_EXIST);
388         $model = new static($modelobj);
390         return $model;
391     }
393     /**
394      * Does this model exist?
395      *
396      * If no indicators are provided it considers any model with the provided
397      * target a match.
398      *
399      * @param \core_analytics\local\target\base $target
400      * @param \core_analytics\local\indicator\base[]|false $indicators
401      * @return bool
402      */
403     public static function exists(\core_analytics\local\target\base $target, $indicators = false) {
404         global $DB;
406         $existingmodels = $DB->get_records('analytics_models', array('target' => $target->get_id()));
408         if (!$existingmodels) {
409             return false;
410         }
412         if (!$indicators && $existingmodels) {
413             return true;
414         }
416         $indicatorids = array_keys($indicators);
417         sort($indicatorids);
419         foreach ($existingmodels as $modelobj) {
420             $model = new \core_analytics\model($modelobj);
421             $modelindicatorids = array_keys($model->get_indicators());
422             sort($modelindicatorids);
424             if ($indicatorids === $modelindicatorids) {
425                 return true;
426             }
427         }
428         return false;
429     }
431     /**
432      * Updates the model.
433      *
434      * @param int|bool $enabled
435      * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
436      * @param string|false $timesplittingid False to respect current time splitting method
437      * @param string|false $predictionsprocessor False to respect current predictors processor value
438      * @return void
439      */
440     public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) {
441         global $USER, $DB;
443         \core_analytics\manager::check_can_manage_models();
445         $now = time();
447         if ($indicators !== false) {
448             $indicatorclasses = self::indicator_classes($indicators);
449             $indicatorsstr = json_encode($indicatorclasses);
450         } else {
451             // Respect current value.
452             $indicatorsstr = $this->model->indicators;
453         }
455         if ($timesplittingid === false) {
456             // Respect current value.
457             $timesplittingid = $this->model->timesplitting;
458         }
460         if ($predictionsprocessor === false) {
461             // Respect current value.
462             $predictionsprocessor = $this->model->predictionsprocessor;
463         }
465         if ($this->model->timesplitting !== $timesplittingid ||
466                 $this->model->indicators !== $indicatorsstr ||
467                 $this->model->predictionsprocessor !== $predictionsprocessor) {
469             // Delete generated predictions before changing the model version.
470             $this->clear();
472             // It needs to be reset as the version changes.
473             $this->uniqueid = null;
474             $this->indicators = null;
476             // We update the version of the model so different time splittings are not mixed up.
477             $this->model->version = $now;
479             // Reset trained flag.
480             if (!$this->is_static()) {
481                 $this->model->trained = 0;
482             }
484         } else if ($this->model->enabled != $enabled) {
485             // We purge the cached contexts with insights as some will not be visible anymore.
486             $this->purge_insights_cache();
487         }
489         $this->model->enabled = intval($enabled);
490         $this->model->indicators = $indicatorsstr;
491         $this->model->timesplitting = $timesplittingid;
492         $this->model->predictionsprocessor = $predictionsprocessor;
493         $this->model->timemodified = $now;
494         $this->model->usermodified = $USER->id;
496         $DB->update_record('analytics_models', $this->model);
497     }
499     /**
500      * Removes the model.
501      *
502      * @return void
503      */
504     public function delete() {
505         global $DB;
507         \core_analytics\manager::check_can_manage_models();
509         $this->clear();
511         // Method self::clear is already clearing the current model version.
512         $predictor = $this->get_predictions_processor(false);
513         if ($predictor->is_ready() !== true) {
514             $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
515             debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
516                 $this->model->id . ' could not be deleted.');
517         } else {
518             $predictor->delete_output_dir($this->get_output_dir(array(), true));
519         }
521         $DB->delete_records('analytics_models', array('id' => $this->model->id));
522         $DB->delete_records('analytics_models_log', array('modelid' => $this->model->id));
523     }
525     /**
526      * Evaluates the model.
527      *
528      * This method gets the site contents (through the analyser) creates a .csv dataset
529      * with them and evaluates the model prediction accuracy multiple times using the
530      * machine learning backend. It returns an object where the model score is the average
531      * prediction accuracy of all executed evaluations.
532      *
533      * @param array $options
534      * @return \stdClass[]
535      */
536     public function evaluate($options = array()) {
538         \core_analytics\manager::check_can_manage_models();
540         if ($this->is_static()) {
541             $this->get_analyser()->add_log(get_string('noevaluationbasedassumptions', 'analytics'));
542             $result = new \stdClass();
543             $result->status = self::NO_DATASET;
544             return array($this->get_time_splitting()->get_id() => $result);
545         }
547         $options['evaluation'] = true;
549         if (empty($options['mode'])) {
550             $options['mode'] = 'configuration';
551         }
553         switch ($options['mode']) {
554             case 'trainedmodel':
556                 // We are only interested on the time splitting method used by the trained model.
557                 $options['timesplitting'] = $this->model->timesplitting;
559                 // Provide the trained model directory to the ML backend if that is what we want to evaluate.
560                 $trainedmodeldir = $this->get_output_dir(['execution']);
561                 break;
562             case 'configuration':
564                 $trainedmodeldir = false;
565                 break;
567             default:
568                 throw new \moodle_exception('errorunknownaction', 'analytics');
569         }
571         $this->init_analyser($options);
573         if (empty($this->get_indicators())) {
574             throw new \moodle_exception('errornoindicators', 'analytics');
575         }
577         $this->heavy_duty_mode();
579         // Before get_labelled_data call so we get an early exception if it is not ready.
580         $predictor = $this->get_predictions_processor();
582         $datasets = $this->get_analyser()->get_labelled_data();
584         // No datasets generated.
585         if (empty($datasets)) {
586             $result = new \stdClass();
587             $result->status = self::NO_DATASET;
588             $result->info = $this->get_analyser()->get_logs();
589             return array($result);
590         }
592         if (!PHPUNIT_TEST && CLI_SCRIPT) {
593             echo PHP_EOL . get_string('processingsitecontents', 'analytics') . PHP_EOL;
594         }
596         $results = array();
597         foreach ($datasets as $timesplittingid => $dataset) {
599             $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingid);
601             $result = new \stdClass();
603             $dashestimesplittingid = str_replace('\\', '', $timesplittingid);
604             $outputdir = $this->get_output_dir(array('evaluation', $dashestimesplittingid));
606             // Evaluate the dataset, the deviation we accept in the results depends on the amount of iterations.
607             if ($this->get_target()->is_linear()) {
608                 $predictorresult = $predictor->evaluate_regression($this->get_unique_id(), self::ACCEPTED_DEVIATION,
609                     self::EVALUATION_ITERATIONS, $dataset, $outputdir, $trainedmodeldir);
610             } else {
611                 $predictorresult = $predictor->evaluate_classification($this->get_unique_id(), self::ACCEPTED_DEVIATION,
612                     self::EVALUATION_ITERATIONS, $dataset, $outputdir, $trainedmodeldir);
613             }
615             $result->status = $predictorresult->status;
616             $result->info = $predictorresult->info;
618             if (isset($predictorresult->score)) {
619                 $result->score = $predictorresult->score;
620             } else {
621                 // Prediction processors may return an error, default to 0 score in that case.
622                 $result->score = 0;
623             }
625             $dir = false;
626             if (!empty($predictorresult->dir)) {
627                 $dir = $predictorresult->dir;
628             }
630             $result->logid = $this->log_result($timesplitting->get_id(), $result->score, $dir, $result->info, $options['mode']);
632             $results[$timesplitting->get_id()] = $result;
633         }
635         return $results;
636     }
638     /**
639      * Trains the model using the site contents.
640      *
641      * This method prepares a dataset from the site contents (through the analyser)
642      * and passes it to the machine learning backends. Static models are skipped as
643      * they do not require training.
644      *
645      * @return \stdClass
646      */
647     public function train() {
649         \core_analytics\manager::check_can_manage_models();
651         if ($this->is_static()) {
652             $this->get_analyser()->add_log(get_string('notrainingbasedassumptions', 'analytics'));
653             $result = new \stdClass();
654             $result->status = self::OK;
655             return $result;
656         }
658         if (!$this->is_enabled() || empty($this->model->timesplitting)) {
659             throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
660         }
662         if (empty($this->get_indicators())) {
663             throw new \moodle_exception('errornoindicators', 'analytics');
664         }
666         $this->heavy_duty_mode();
668         // Before get_labelled_data call so we get an early exception if it is not writable.
669         $outputdir = $this->get_output_dir(array('execution'));
671         // Before get_labelled_data call so we get an early exception if it is not ready.
672         $predictor = $this->get_predictions_processor();
674         $datasets = $this->get_analyser()->get_labelled_data();
676         // No training if no files have been provided.
677         if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
679             $result = new \stdClass();
680             $result->status = self::NO_DATASET;
681             $result->info = $this->get_analyser()->get_logs();
682             return $result;
683         }
684         $samplesfile = $datasets[$this->model->timesplitting];
686         // Train using the dataset.
687         if ($this->get_target()->is_linear()) {
688             $predictorresult = $predictor->train_regression($this->get_unique_id(), $samplesfile, $outputdir);
689         } else {
690             $predictorresult = $predictor->train_classification($this->get_unique_id(), $samplesfile, $outputdir);
691         }
693         $result = new \stdClass();
694         $result->status = $predictorresult->status;
695         $result->info = $predictorresult->info;
697         if ($result->status !== self::OK) {
698             return $result;
699         }
701         $this->flag_file_as_used($samplesfile, 'trained');
703         // Mark the model as trained if it wasn't.
704         if ($this->model->trained == false) {
705             $this->mark_as_trained();
706         }
708         return $result;
709     }
711     /**
712      * Get predictions from the site contents.
713      *
714      * It analyses the site contents (through analyser classes) looking for samples
715      * ready to receive predictions. It generates a dataset with all samples ready to
716      * get predictions and it passes it to the machine learning backends or to the
717      * targets based on assumptions to get the predictions.
718      *
719      * @return \stdClass
720      */
721     public function predict() {
722         global $DB;
724         \core_analytics\manager::check_can_manage_models();
726         if (!$this->is_enabled() || empty($this->model->timesplitting)) {
727             throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
728         }
730         if (empty($this->get_indicators())) {
731             throw new \moodle_exception('errornoindicators', 'analytics');
732         }
734         $this->heavy_duty_mode();
736         // Before get_unlabelled_data call so we get an early exception if it is not writable.
737         $outputdir = $this->get_output_dir(array('execution'));
739         // Before get_unlabelled_data call so we get an early exception if it is not ready.
740         if (!$this->is_static()) {
741             $predictor = $this->get_predictions_processor();
742         }
744         $samplesdata = $this->get_analyser()->get_unlabelled_data();
746         // Get the prediction samples file.
747         if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
749             $result = new \stdClass();
750             $result->status = self::NO_DATASET;
751             $result->info = $this->get_analyser()->get_logs();
752             return $result;
753         }
754         $samplesfile = $samplesdata[$this->model->timesplitting];
756         // We need to throw an exception if we are trying to predict stuff that was already predicted.
757         $params = array('modelid' => $this->model->id, 'action' => 'predicted', 'fileid' => $samplesfile->get_id());
758         if ($predicted = $DB->get_record('analytics_used_files', $params)) {
759             throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id());
760         }
762         $indicatorcalculations = \core_analytics\dataset_manager::get_structured_data($samplesfile);
764         // Prepare the results object.
765         $result = new \stdClass();
767         if ($this->is_static()) {
768             // Prediction based on assumptions.
769             $result->status = self::OK;
770             $result->info = [];
771             $result->predictions = $this->get_static_predictions($indicatorcalculations);
773         } else {
774             // Estimation and classification processes run on the machine learning backend side.
775             if ($this->get_target()->is_linear()) {
776                 $predictorresult = $predictor->estimate($this->get_unique_id(), $samplesfile, $outputdir);
777             } else {
778                 $predictorresult = $predictor->classify($this->get_unique_id(), $samplesfile, $outputdir);
779             }
780             $result->status = $predictorresult->status;
781             $result->info = $predictorresult->info;
782             $result->predictions = $this->format_predictor_predictions($predictorresult);
783         }
785         if ($result->status !== self::OK) {
786             return $result;
787         }
789         if ($result->predictions) {
790             $samplecontexts = $this->execute_prediction_callbacks($result->predictions, $indicatorcalculations);
791         }
793         if (!empty($samplecontexts) && $this->uses_insights()) {
794             $this->trigger_insights($samplecontexts);
795         }
797         $this->flag_file_as_used($samplesfile, 'predicted');
799         return $result;
800     }
802     /**
803      * Returns the model predictions processor.
804      *
805      * @param bool $checkisready
806      * @return \core_analytics\predictor
807      */
808     public function get_predictions_processor($checkisready = true) {
809         return manager::get_predictions_processor($this->model->predictionsprocessor, $checkisready);
810     }
812     /**
813      * Formats the predictor results.
814      *
815      * @param array $predictorresult
816      * @return array
817      */
818     private function format_predictor_predictions($predictorresult) {
820         $predictions = array();
821         if (!empty($predictorresult->predictions)) {
822             foreach ($predictorresult->predictions as $sampleinfo) {
824                 // We parse each prediction.
825                 switch (count($sampleinfo)) {
826                     case 1:
827                         // For whatever reason the predictions processor could not process this sample, we
828                         // skip it and do nothing with it.
829                         debugging($this->model->id . ' model predictions processor could not process the sample with id ' .
830                             $sampleinfo[0], DEBUG_DEVELOPER);
831                         continue 2;
832                     case 2:
833                         // Prediction processors that do not return a prediction score will have the maximum prediction
834                         // score.
835                         list($uniquesampleid, $prediction) = $sampleinfo;
836                         $predictionscore = 1;
837                         break;
838                     case 3:
839                         list($uniquesampleid, $prediction, $predictionscore) = $sampleinfo;
840                         break;
841                     default:
842                         break;
843                 }
844                 $predictiondata = (object)['prediction' => $prediction, 'predictionscore' => $predictionscore];
845                 $predictions[$uniquesampleid] = $predictiondata;
846             }
847         }
848         return $predictions;
849     }
851     /**
852      * Execute the prediction callbacks defined by the target.
853      *
854      * @param \stdClass[] $predictions
855      * @param array $indicatorcalculations
856      * @return array
857      */
858     protected function execute_prediction_callbacks($predictions, $indicatorcalculations) {
860         // Here we will store all predictions' contexts, this will be used to limit which users will see those predictions.
861         $samplecontexts = array();
862         $records = array();
864         foreach ($predictions as $uniquesampleid => $prediction) {
866             // The unique sample id contains both the sampleid and the rangeindex.
867             list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
869             if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
871                 // Prepare the record to store the predicted values.
872                 list($record, $samplecontext) = $this->prepare_prediction_record($sampleid, $rangeindex, $prediction->prediction,
873                     $prediction->predictionscore, json_encode($indicatorcalculations[$uniquesampleid]));
875                 // We will later bulk-insert them all.
876                 $records[$uniquesampleid] = $record;
878                 // Also store all samples context to later generate insights or whatever action the target wants to perform.
879                 $samplecontexts[$samplecontext->id] = $samplecontext;
881                 $this->get_target()->prediction_callback($this->model->id, $sampleid, $rangeindex, $samplecontext,
882                     $prediction->prediction, $prediction->predictionscore);
883             }
884         }
886         if (!empty($records)) {
887             $this->save_predictions($records);
888         }
890         return $samplecontexts;
891     }
893     /**
894      * Generates insights and updates the cache.
895      *
896      * @param \context[] $samplecontexts
897      * @return void
898      */
899     protected function trigger_insights($samplecontexts) {
901         // Notify the target that all predictions have been processed.
902         $this->get_target()->generate_insight_notifications($this->model->id, $samplecontexts);
904         // Update cache.
905         $cache = \cache::make('core', 'contextwithinsights');
906         foreach ($samplecontexts as $context) {
907             $modelids = $cache->get($context->id);
908             if (!$modelids) {
909                 // The cache is empty, but we don't know if it is empty because there are no insights
910                 // in this context or because cache/s have been purged, we need to be conservative and
911                 // "pay" 1 db read to fill up the cache.
912                 $models = \core_analytics\manager::get_models_with_insights($context);
913                 $cache->set($context->id, array_keys($models));
914             } else if (!in_array($this->get_id(), $modelids)) {
915                 array_push($modelids, $this->get_id());
916                 $cache->set($context->id, $modelids);
917             }
918         }
919     }
921     /**
922      * Get predictions from a static model.
923      *
924      * @param array $indicatorcalculations
925      * @return \stdClass[]
926      */
927     protected function get_static_predictions(&$indicatorcalculations) {
929         // Group samples by analysable for \core_analytics\local\target::calculate.
930         $analysables = array();
931         // List all sampleids together.
932         $sampleids = array();
934         foreach ($indicatorcalculations as $uniquesampleid => $indicators) {
935             list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
937             $analysable = $this->get_analyser()->get_sample_analysable($sampleid);
938             $analysableclass = get_class($analysable);
939             if (empty($analysables[$analysableclass])) {
940                 $analysables[$analysableclass] = array();
941             }
942             if (empty($analysables[$analysableclass][$rangeindex])) {
943                 $analysables[$analysableclass][$rangeindex] = (object)[
944                     'analysable' => $analysable,
945                     'indicatorsdata' => array(),
946                     'sampleids' => array()
947                 ];
948             }
949             // Using the sampleid as a key so we can easily merge indicators data later.
950             $analysables[$analysableclass][$rangeindex]->indicatorsdata[$sampleid] = $indicators;
951             // We could use indicatorsdata keys but the amount of redundant data is not that big and leaves code below cleaner.
952             $analysables[$analysableclass][$rangeindex]->sampleids[$sampleid] = $sampleid;
954             // Accumulate sample ids to get all their associated data in 1 single db query (analyser::get_samples).
955             $sampleids[$sampleid] = $sampleid;
956         }
958         // Get all samples data.
959         list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
961         // Calculate the targets.
962         $predictions = array();
963         foreach ($analysables as $analysableclass => $rangedata) {
964             foreach ($rangedata as $rangeindex => $data) {
966                 // Attach samples data and calculated indicators data.
967                 $this->get_target()->clear_sample_data();
968                 $this->get_target()->add_sample_data($samplesdata);
969                 $this->get_target()->add_sample_data($data->indicatorsdata);
971                 // Append new elements (we can not get duplicates because sample-analysable relation is N-1).
972                 $range = $this->get_time_splitting()->get_range_by_index($rangeindex);
973                 $this->get_target()->filter_out_invalid_samples($data->sampleids, $data->analysable, false);
974                 $calculations = $this->get_target()->calculate($data->sampleids, $data->analysable, $range['start'], $range['end']);
976                 // Missing $indicatorcalculations values in $calculations are caused by is_valid_sample. We need to remove
977                 // these $uniquesampleid from $indicatorcalculations because otherwise they will be stored as calculated
978                 // by self::save_prediction.
979                 $indicatorcalculations = array_filter($indicatorcalculations, function($indicators, $uniquesampleid) use ($calculations) {
980                     list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
981                     if (!isset($calculations[$sampleid])) {
982                         return false;
983                     }
984                     return true;
985                 }, ARRAY_FILTER_USE_BOTH);
987                 foreach ($calculations as $sampleid => $value) {
989                     $uniquesampleid = $this->get_time_splitting()->append_rangeindex($sampleid, $rangeindex);
991                     // Null means that the target couldn't calculate the sample, we also remove them from $indicatorcalculations.
992                     if (is_null($calculations[$sampleid])) {
993                         unset($indicatorcalculations[$uniquesampleid]);
994                         continue;
995                     }
997                     // Even if static predictions are based on assumptions we flag them as 100% because they are 100%
998                     // true according to what the developer defined.
999                     $predictions[$uniquesampleid] = (object)['prediction' => $value, 'predictionscore' => 1];
1000                 }
1001             }
1002         }
1003         return $predictions;
1004     }
1006     /**
1007      * Stores the prediction in the database.
1008      *
1009      * @param int $sampleid
1010      * @param int $rangeindex
1011      * @param int $prediction
1012      * @param float $predictionscore
1013      * @param string $calculations
1014      * @return \context
1015      */
1016     protected function prepare_prediction_record($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) {
1017         $context = $this->get_analyser()->sample_access_context($sampleid);
1019         $record = new \stdClass();
1020         $record->modelid = $this->model->id;
1021         $record->contextid = $context->id;
1022         $record->sampleid = $sampleid;
1023         $record->rangeindex = $rangeindex;
1024         $record->prediction = $prediction;
1025         $record->predictionscore = $predictionscore;
1026         $record->calculations = $calculations;
1027         $record->timecreated = time();
1029         $analysable = $this->get_analyser()->get_sample_analysable($sampleid);
1030         $timesplitting = $this->get_time_splitting();
1031         $timesplitting->set_analysable($analysable);
1032         $range = $timesplitting->get_range_by_index($rangeindex);
1033         if ($range) {
1034             $record->timestart = $range['start'];
1035             $record->timeend = $range['end'];
1036         }
1038         return array($record, $context);
1039     }
1041     /**
1042      * Save the prediction objects.
1043      *
1044      * @param \stdClass[] $records
1045      */
1046     protected function save_predictions($records) {
1047         global $DB;
1048         $DB->insert_records('analytics_predictions', $records);
1049     }
1051     /**
1052      * Enabled the model using the provided time splitting method.
1053      *
1054      * @param string|false $timesplittingid False to respect the current time splitting method.
1055      * @return void
1056      */
1057     public function enable($timesplittingid = false) {
1058         global $DB, $USER;
1060         $now = time();
1062         if ($timesplittingid && $timesplittingid !== $this->model->timesplitting) {
1064             if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) {
1065                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
1066             }
1068             if (substr($timesplittingid, 0, 1) !== '\\') {
1069                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
1070             }
1072             // Delete generated predictions before changing the model version.
1073             $this->clear();
1075             // It needs to be reset as the version changes.
1076             $this->uniqueid = null;
1078             $this->model->timesplitting = $timesplittingid;
1079             $this->model->version = $now;
1081             // Reset trained flag.
1082             if (!$this->is_static()) {
1083                 $this->model->trained = 0;
1084             }
1085         } else if (empty($this->model->timesplitting)) {
1086             // A valid timesplitting method needs to be supplied before a model can be enabled.
1087             throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
1089         }
1091         // Purge pages with insights as this may change things.
1092         if ($this->model->enabled != 1) {
1093             $this->purge_insights_cache();
1094         }
1096         $this->model->enabled = 1;
1097         $this->model->timemodified = $now;
1098         $this->model->usermodified = $USER->id;
1100         // We don't always update timemodified intentionally as we reserve it for target, indicators or timesplitting updates.
1101         $DB->update_record('analytics_models', $this->model);
1102     }
1104     /**
1105      * Is this a static model (as defined by the target)?.
1106      *
1107      * Static models are based on assumptions instead of in machine learning
1108      * backends results.
1109      *
1110      * @return bool
1111      */
1112     public function is_static() {
1113         return (bool)$this->get_target()->based_on_assumptions();
1114     }
1116     /**
1117      * Is this model enabled?
1118      *
1119      * @return bool
1120      */
1121     public function is_enabled() {
1122         return (bool)$this->model->enabled;
1123     }
1125     /**
1126      * Is this model already trained?
1127      *
1128      * @return bool
1129      */
1130     public function is_trained() {
1131         // Models which targets are based on assumptions do not need training.
1132         return (bool)$this->model->trained || $this->is_static();
1133     }
1135     /**
1136      * Marks the model as trained
1137      *
1138      * @return void
1139      */
1140     public function mark_as_trained() {
1141         global $DB;
1143         \core_analytics\manager::check_can_manage_models();
1145         $this->model->trained = 1;
1146         $DB->update_record('analytics_models', $this->model);
1147     }
1149     /**
1150      * Get the contexts with predictions.
1151      *
1152      * @param bool $skiphidden Skip hidden predictions
1153      * @return \stdClass[]
1154      */
1155     public function get_predictions_contexts($skiphidden = true) {
1156         global $DB, $USER;
1158         $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
1159                   JOIN {context} ctx ON ctx.id = ap.contextid
1160                  WHERE ap.modelid = :modelid";
1161         $params = array('modelid' => $this->model->id);
1163         if ($skiphidden) {
1164             $sql .= " AND NOT EXISTS (
1165               SELECT 1
1166                 FROM {analytics_prediction_actions} apa
1167                WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
1168             )";
1169             $params['userid'] = $USER->id;
1170             $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
1171             $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
1172         }
1174         return $DB->get_records_sql($sql, $params);
1175     }
1177     /**
1178      * Has this model generated predictions?
1179      *
1180      * We don't check analytics_predictions table because targets have the ability to
1181      * ignore some predicted values, if that is the case predictions are not even stored
1182      * in db.
1183      *
1184      * @return bool
1185      */
1186     public function any_prediction_obtained() {
1187         global $DB;
1188         return $DB->record_exists('analytics_predict_samples',
1189             array('modelid' => $this->model->id, 'timesplitting' => $this->model->timesplitting));
1190     }
1192     /**
1193      * Whether this model generates insights or not (defined by the model's target).
1194      *
1195      * @return bool
1196      */
1197     public function uses_insights() {
1198         $target = $this->get_target();
1199         return $target::uses_insights();
1200     }
1202     /**
1203      * Whether predictions exist for this context.
1204      *
1205      * @param \context $context
1206      * @return bool
1207      */
1208     public function predictions_exist(\context $context) {
1209         global $DB;
1211         // Filters out previous predictions keeping only the last time range one.
1212         $select = "modelid = :modelid AND contextid = :contextid";
1213         $params = array('modelid' => $this->model->id, 'contextid' => $context->id);
1214         return $DB->record_exists_select('analytics_predictions', $select, $params);
1215     }
1217     /**
1218      * Gets the predictions for this context.
1219      *
1220      * @param \context $context
1221      * @param bool $skiphidden Skip hidden predictions
1222      * @param int $page The page of results to fetch. False for all results.
1223      * @param int $perpage The max number of results to fetch. Ignored if $page is false.
1224      * @return array($total, \core_analytics\prediction[])
1225      */
1226     public function get_predictions(\context $context, $skiphidden = true, $page = false, $perpage = 100) {
1227         global $DB, $USER;
1229         \core_analytics\manager::check_can_list_insights($context);
1231         // Filters out previous predictions keeping only the last time range one.
1232         $sql = "SELECT ap.*
1233                   FROM {analytics_predictions} ap
1234                   JOIN (
1235                     SELECT sampleid, max(rangeindex) AS rangeindex
1236                       FROM {analytics_predictions}
1237                      WHERE modelid = :modelidsubap and contextid = :contextidsubap
1238                     GROUP BY sampleid
1239                   ) apsub
1240                   ON ap.sampleid = apsub.sampleid AND ap.rangeindex = apsub.rangeindex
1241                 WHERE ap.modelid = :modelid and ap.contextid = :contextid";
1243         $params = array('modelid' => $this->model->id, 'contextid' => $context->id,
1244             'modelidsubap' => $this->model->id, 'contextidsubap' => $context->id);
1246         if ($skiphidden) {
1247             $sql .= " AND NOT EXISTS (
1248               SELECT 1
1249                 FROM {analytics_prediction_actions} apa
1250                WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
1251             )";
1252             $params['userid'] = $USER->id;
1253             $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
1254             $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
1255         }
1257         $sql .= " ORDER BY ap.timecreated DESC";
1258         if (!$predictions = $DB->get_records_sql($sql, $params)) {
1259             return array();
1260         }
1262         // Get predicted samples' ids.
1263         $sampleids = array_map(function($prediction) {
1264             return $prediction->sampleid;
1265         }, $predictions);
1267         list($unused, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
1269         $current = 0;
1271         if ($page !== false) {
1272             $offset = $page * $perpage;
1273             $limit = $offset + $perpage;
1274         }
1276         foreach ($predictions as $predictionid => $predictiondata) {
1278             $sampleid = $predictiondata->sampleid;
1280             // Filter out predictions which samples are not available anymore.
1281             if (empty($samplesdata[$sampleid])) {
1282                 unset($predictions[$predictionid]);
1283                 continue;
1284             }
1286             // Return paginated dataset - we cannot paginate in the DB because we post filter the list.
1287             if ($page === false || ($current >= $offset && $current < $limit)) {
1288                 // Replace \stdClass object by \core_analytics\prediction objects.
1289                 $prediction = new \core_analytics\prediction($predictiondata, $samplesdata[$sampleid]);
1290                 $predictions[$predictionid] = $prediction;
1291             } else {
1292                 unset($predictions[$predictionid]);
1293             }
1295             $current++;
1296         }
1298         return [$current, $predictions];
1299     }
1301     /**
1302      * Returns the sample data of a prediction.
1303      *
1304      * @param \stdClass $predictionobj
1305      * @return array
1306      */
1307     public function prediction_sample_data($predictionobj) {
1309         list($unused, $samplesdata) = $this->get_analyser()->get_samples(array($predictionobj->sampleid));
1311         if (empty($samplesdata[$predictionobj->sampleid])) {
1312             throw new \moodle_exception('errorsamplenotavailable', 'analytics');
1313         }
1315         return $samplesdata[$predictionobj->sampleid];
1316     }
1318     /**
1319      * Returns the description of a sample
1320      *
1321      * @param \core_analytics\prediction $prediction
1322      * @return array 2 elements: list(string, \renderable)
1323      */
1324     public function prediction_sample_description(\core_analytics\prediction $prediction) {
1325         return $this->get_analyser()->sample_description($prediction->get_prediction_data()->sampleid,
1326             $prediction->get_prediction_data()->contextid, $prediction->get_sample_data());
1327     }
1329     /**
1330      * Returns the output directory for prediction processors.
1331      *
1332      * Directory structure as follows:
1333      * - Evaluation runs:
1334      *   models/$model->id/$model->version/evaluation/$model->timesplitting
1335      * - Training  & prediction runs:
1336      *   models/$model->id/$model->version/execution
1337      *
1338      * @param array $subdirs
1339      * @param bool $onlymodelid Preference over $subdirs
1340      * @return string
1341      */
1342     public function get_output_dir($subdirs = array(), $onlymodelid = false) {
1343         global $CFG;
1345         $subdirstr = '';
1346         foreach ($subdirs as $subdir) {
1347             $subdirstr .= DIRECTORY_SEPARATOR . $subdir;
1348         }
1350         $outputdir = get_config('analytics', 'modeloutputdir');
1351         if (empty($outputdir)) {
1352             // Apply default value.
1353             $outputdir = rtrim($CFG->dataroot, '/') . DIRECTORY_SEPARATOR . 'models';
1354         }
1356         // Append model id.
1357         $outputdir .= DIRECTORY_SEPARATOR . $this->model->id;
1358         if (!$onlymodelid) {
1359             // Append version + subdirs.
1360             $outputdir .= DIRECTORY_SEPARATOR . $this->model->version . $subdirstr;
1361         }
1363         make_writable_directory($outputdir);
1365         return $outputdir;
1366     }
1368     /**
1369      * Returns a unique id for this model.
1370      *
1371      * This id should be unique for this site.
1372      *
1373      * @return string
1374      */
1375     public function get_unique_id() {
1376         global $CFG;
1378         if (!is_null($this->uniqueid)) {
1379             return $this->uniqueid;
1380         }
1382         // Generate a unique id for this site, this model and this time splitting method, considering the last time
1383         // that the model target and indicators were updated.
1384         $ids = array($CFG->wwwroot, $CFG->prefix, $this->model->id, $this->model->version);
1385         $this->uniqueid = sha1(implode('$$', $ids));
1387         return $this->uniqueid;
1388     }
1390     /**
1391      * Exports the model data for displaying it in a template.
1392      *
1393      * @return \stdClass
1394      */
1395     public function export() {
1397         \core_analytics\manager::check_can_manage_models();
1399         $data = clone $this->model;
1400         $data->target = $this->get_target()->get_name();
1402         if ($timesplitting = $this->get_time_splitting()) {
1403             $data->timesplitting = $timesplitting->get_name();
1404         }
1406         $data->indicators = array();
1407         foreach ($this->get_indicators() as $indicator) {
1408             $data->indicators[] = $indicator->get_name();
1409         }
1410         return $data;
1411     }
1413     /**
1414      * Exports the model data to a zip file.
1415      *
1416      * @param string $zipfilename
1417      * @return string Zip file path
1418      */
1419     public function export_model(string $zipfilename) : string {
1421         \core_analytics\manager::check_can_manage_models();
1423         $modelconfig = new model_config($this);
1424         return $modelconfig->export($zipfilename);
1425     }
1427     /**
1428      * Imports the provided model.
1429      *
1430      * Note that this method assumes that model_config::check_dependencies has already been called.
1431      *
1432      * @throws \moodle_exception
1433      * @param  string $zipfilepath Zip file path
1434      * @return \core_analytics\model
1435      */
1436     public static function import_model(string $zipfilepath) : \core_analytics\model {
1438         \core_analytics\manager::check_can_manage_models();
1440         $modelconfig = new \core_analytics\model_config();
1441         return $modelconfig->import($zipfilepath);
1442     }
1444     /**
1445      * Can this model be exported?
1446      *
1447      * @return bool
1448      */
1449     public function can_export_configuration() : bool {
1451         if (empty($this->model->timesplitting)) {
1452             return false;
1453         }
1454         if (!$this->get_indicators()) {
1455             return false;
1456         }
1458         if ($this->is_static()) {
1459             return false;
1460         }
1462         return true;
1463     }
1465     /**
1466      * Returns the model logs data.
1467      *
1468      * @param int $limitfrom
1469      * @param int $limitnum
1470      * @return \stdClass[]
1471      */
1472     public function get_logs($limitfrom = 0, $limitnum = 0) {
1473         global $DB;
1475         \core_analytics\manager::check_can_manage_models();
1477         return $DB->get_records('analytics_models_log', array('modelid' => $this->get_id()), 'timecreated DESC', '*',
1478             $limitfrom, $limitnum);
1479     }
1481     /**
1482      * Merges all training data files into one and returns it.
1483      *
1484      * @return \stored_file|false
1485      */
1486     public function get_training_data() {
1488         \core_analytics\manager::check_can_manage_models();
1490         $timesplittingid = $this->get_time_splitting()->get_id();
1491         return \core_analytics\dataset_manager::export_training_data($this->get_id(), $timesplittingid);
1492     }
1494     /**
1495      * Has the model been trained using data from this site?
1496      *
1497      * This method is useful to determine if a trained model can be evaluated as
1498      * we can not use the same data for training and for evaluation.
1499      *
1500      * @return bool
1501      */
1502     public function trained_locally() : bool {
1503         global $DB;
1505         if (!$this->is_trained() || $this->is_static()) {
1506             // Early exit.
1507             return false;
1508         }
1510         if ($DB->record_exists('analytics_train_samples', ['modelid' => $this->model->id])) {
1511             return true;
1512         }
1514         return false;
1515     }
1517     /**
1518      * Flag the provided file as used for training or prediction.
1519      *
1520      * @param \stored_file $file
1521      * @param string $action
1522      * @return void
1523      */
1524     protected function flag_file_as_used(\stored_file $file, $action) {
1525         global $DB;
1527         $usedfile = new \stdClass();
1528         $usedfile->modelid = $this->model->id;
1529         $usedfile->fileid = $file->get_id();
1530         $usedfile->action = $action;
1531         $usedfile->time = time();
1532         $DB->insert_record('analytics_used_files', $usedfile);
1533     }
1535     /**
1536      * Log the evaluation results in the database.
1537      *
1538      * @param string $timesplittingid
1539      * @param float $score
1540      * @param string $dir
1541      * @param array $info
1542      * @param string $evaluationmode
1543      * @return int The inserted log id
1544      */
1545     protected function log_result($timesplittingid, $score, $dir = false, $info = false, $evaluationmode = 'configuration') {
1546         global $DB, $USER;
1548         $log = new \stdClass();
1549         $log->modelid = $this->get_id();
1550         $log->version = $this->model->version;
1551         $log->evaluationmode = $evaluationmode;
1552         $log->target = $this->model->target;
1553         $log->indicators = $this->model->indicators;
1554         $log->timesplitting = $timesplittingid;
1555         $log->dir = $dir;
1556         if ($info) {
1557             // Ensure it is not an associative array.
1558             $log->info = json_encode(array_values($info));
1559         }
1560         $log->score = $score;
1561         $log->timecreated = time();
1562         $log->usermodified = $USER->id;
1564         return $DB->insert_record('analytics_models_log', $log);
1565     }
1567     /**
1568      * Utility method to return indicator class names from a list of indicator objects
1569      *
1570      * @param \core_analytics\local\indicator\base[] $indicators
1571      * @return string[]
1572      */
1573     private static function indicator_classes($indicators) {
1575         // What we want to check and store are the indicator classes not the keys.
1576         $indicatorclasses = array();
1577         foreach ($indicators as $indicator) {
1578             if (!\core_analytics\manager::is_valid($indicator, '\core_analytics\local\indicator\base')) {
1579                 if (!is_object($indicator) && !is_scalar($indicator)) {
1580                     $indicator = strval($indicator);
1581                 } else if (is_object($indicator)) {
1582                     $indicator = '\\' . get_class($indicator);
1583                 }
1584                 throw new \moodle_exception('errorinvalidindicator', 'analytics', '', $indicator);
1585             }
1586             $indicatorclasses[] = $indicator->get_id();
1587         }
1589         return $indicatorclasses;
1590     }
1592     /**
1593      * Clears the model training and prediction data.
1594      *
1595      * Executed after updating model critical elements like the time splitting method
1596      * or the indicators.
1597      *
1598      * @return void
1599      */
1600     public function clear() {
1601         global $DB, $USER;
1603         \core_analytics\manager::check_can_manage_models();
1605         // Delete current model version stored stuff.
1606         $predictor = $this->get_predictions_processor(false);
1607         if ($predictor->is_ready() !== true) {
1608             $predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
1609             debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
1610                 $this->model->id . ' could not be cleared.');
1611         } else {
1612             $predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
1613         }
1615         $predictionids = $DB->get_fieldset_select('analytics_predictions', 'id', 'modelid = :modelid',
1616             array('modelid' => $this->get_id()));
1617         if ($predictionids) {
1618             list($sql, $params) = $DB->get_in_or_equal($predictionids);
1619             $DB->delete_records_select('analytics_prediction_actions', "predictionid $sql", $params);
1620         }
1622         $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id));
1623         $DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id));
1624         $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
1625         $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
1626         $DB->delete_records('analytics_used_analysables', array('modelid' => $this->model->id));
1628         // Purge all generated files.
1629         \core_analytics\dataset_manager::clear_model_files($this->model->id);
1631         // We don't expect people to clear models regularly and the cost of filling the cache is
1632         // 1 db read per context.
1633         $this->purge_insights_cache();
1635         if (!$this->is_static()) {
1636             $this->model->trained = 0;
1637         }
1639         $this->model->timemodified = time();
1640         $this->model->usermodified = $USER->id;
1641         $DB->update_record('analytics_models', $this->model);
1642     }
1644     /**
1645      * Purges the insights cache.
1646      */
1647     private function purge_insights_cache() {
1648         $cache = \cache::make('core', 'contextwithinsights');
1649         $cache->purge();
1650     }
1652     /**
1653      * Increases system memory and time limits.
1654      *
1655      * @return void
1656      */
1657     private function heavy_duty_mode() {
1658         if (ini_get('memory_limit') != -1) {
1659             raise_memory_limit(MEMORY_HUGE);
1660         }
1661         \core_php_time_limit::raise();
1662     }