a76acd5c09648d46a418b831b53bb1d68a916eb4
[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      * Maximum standard deviation between different evaluation repetitions to consider that evaluation results are stable.
85      */
86     const ACCEPTED_DEVIATION = 0.05;
88     /**
89      * Number of evaluation repetitions.
90      */
91     const EVALUATION_ITERATIONS = 10;
93     /**
94      * @var \stdClass
95      */
96     protected $model = null;
98     /**
99      * @var \core_analytics\local\analyser\base
100      */
101     protected $analyser = null;
103     /**
104      * @var \core_analytics\local\target\base
105      */
106     protected $target = null;
108     /**
109      * @var \core_analytics\local\indicator\base[]
110      */
111     protected $indicators = null;
113     /**
114      * Unique Model id created from site info and last model modification.
115      *
116      * @var string
117      */
118     protected $uniqueid = null;
120     /**
121      * Constructor.
122      *
123      * @param int|\stdClass $model
124      * @return void
125      */
126     public function __construct($model) {
127         global $DB;
129         if (is_scalar($model)) {
130             $model = $DB->get_record('analytics_models', array('id' => $model), '*', MUST_EXIST);
131             if (!$model) {
132                 throw new \moodle_exception('errorunexistingmodel', 'analytics', '', $model);
133             }
134         }
135         $this->model = $model;
136     }
138     /**
139      * Quick safety check to discard site models which required components are not available anymore.
140      *
141      * @return bool
142      */
143     public function is_available() {
144         $target = $this->get_target();
145         if (!$target) {
146             return false;
147         }
149         $classname = $target->get_analyser_class();
150         if (!class_exists($classname)) {
151             return false;
152         }
154         return true;
155     }
157     /**
158      * Returns the model id.
159      *
160      * @return int
161      */
162     public function get_id() {
163         return $this->model->id;
164     }
166     /**
167      * Returns a plain \stdClass with the model data.
168      *
169      * @return \stdClass
170      */
171     public function get_model_obj() {
172         return $this->model;
173     }
175     /**
176      * Returns the model target.
177      *
178      * @return \core_analytics\local\target\base
179      */
180     public function get_target() {
181         if ($this->target !== null) {
182             return $this->target;
183         }
184         $instance = \core_analytics\manager::get_target($this->model->target);
185         $this->target = $instance;
187         return $this->target;
188     }
190     /**
191      * Returns the model indicators.
192      *
193      * @return \core_analytics\local\indicator\base[]
194      */
195     public function get_indicators() {
196         if ($this->indicators !== null) {
197             return $this->indicators;
198         }
200         $fullclassnames = json_decode($this->model->indicators);
202         if (!is_array($fullclassnames)) {
203             throw new \coding_exception('Model ' . $this->model->id . ' indicators can not be read');
204         }
206         $this->indicators = array();
207         foreach ($fullclassnames as $fullclassname) {
208             $instance = \core_analytics\manager::get_indicator($fullclassname);
209             if ($instance) {
210                 $this->indicators[$fullclassname] = $instance;
211             } else {
212                 debugging('Can\'t load ' . $fullclassname . ' indicator', DEBUG_DEVELOPER);
213             }
214         }
216         return $this->indicators;
217     }
219     /**
220      * Returns the list of indicators that could potentially be used by the model target.
221      *
222      * It includes the indicators that are part of the model.
223      *
224      * @return \core_analytics\local\indicator\base[]
225      */
226     public function get_potential_indicators() {
228         $indicators = \core_analytics\manager::get_all_indicators();
230         if (empty($this->analyser)) {
231             $this->init_analyser(array('evaluation' => true));
232         }
234         foreach ($indicators as $classname => $indicator) {
235             if ($this->analyser->check_indicator_requirements($indicator) !== true) {
236                 unset($indicators[$classname]);
237             }
238         }
239         return $indicators;
240     }
242     /**
243      * Returns the model analyser (defined by the model target).
244      *
245      * @return \core_analytics\local\analyser\base
246      */
247     public function get_analyser() {
248         if ($this->analyser !== null) {
249             return $this->analyser;
250         }
252         // Default initialisation with no options.
253         $this->init_analyser();
255         return $this->analyser;
256     }
258     /**
259      * Initialises the model analyser.
260      *
261      * @throws \coding_exception
262      * @param array $options
263      * @return void
264      */
265     protected function init_analyser($options = array()) {
267         $target = $this->get_target();
268         $indicators = $this->get_indicators();
270         if (empty($target)) {
271             throw new \moodle_exception('errornotarget', 'analytics');
272         }
274         if (!empty($options['evaluation'])) {
275             // The evaluation process will run using all available time splitting methods unless one is specified.
276             if (!empty($options['timesplitting'])) {
277                 $timesplitting = \core_analytics\manager::get_time_splitting($options['timesplitting']);
278                 $timesplittings = array($timesplitting->get_id() => $timesplitting);
279             } else {
280                 $timesplittings = \core_analytics\manager::get_enabled_time_splitting_methods();
281             }
282         } else {
284             if (empty($this->model->timesplitting)) {
285                 throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
286             }
288             // Returned as an array as all actions (evaluation, training and prediction) go through the same process.
289             $timesplittings = array($this->model->timesplitting => $this->get_time_splitting());
290         }
292         if (empty($timesplittings)) {
293             throw new \moodle_exception('errornotimesplittings', 'analytics');
294         }
296         $classname = $target->get_analyser_class();
297         if (!class_exists($classname)) {
298             throw new \coding_exception($classname . ' class does not exists');
299         }
301         // Returns a \core_analytics\local\analyser\base class.
302         $this->analyser = new $classname($this->model->id, $target, $indicators, $timesplittings, $options);
303     }
305     /**
306      * Returns the model time splitting method.
307      *
308      * @return \core_analytics\local\time_splitting\base|false Returns false if no time splitting.
309      */
310     public function get_time_splitting() {
311         if (empty($this->model->timesplitting)) {
312             return false;
313         }
314         return \core_analytics\manager::get_time_splitting($this->model->timesplitting);
315     }
317     /**
318      * Creates a new model. Enables it if $timesplittingid is specified.
319      *
320      * @param \core_analytics\local\target\base $target
321      * @param \core_analytics\local\indicator\base[] $indicators
322      * @param string $timesplittingid The time splitting method id (its fully qualified class name)
323      * @return \core_analytics\model
324      */
325     public static function create(\core_analytics\local\target\base $target, array $indicators, $timesplittingid = false) {
326         global $USER, $DB;
328         \core_analytics\manager::check_can_manage_models();
330         $indicatorclasses = self::indicator_classes($indicators);
332         $now = time();
334         $modelobj = new \stdClass();
335         $modelobj->target = $target->get_id();
336         $modelobj->indicators = json_encode($indicatorclasses);
337         $modelobj->version = $now;
338         $modelobj->timecreated = $now;
339         $modelobj->timemodified = $now;
340         $modelobj->usermodified = $USER->id;
342         $id = $DB->insert_record('analytics_models', $modelobj);
344         // Get db defaults.
345         $modelobj = $DB->get_record('analytics_models', array('id' => $id), '*', MUST_EXIST);
347         $model = new static($modelobj);
349         if ($timesplittingid) {
350             $model->enable($timesplittingid);
351         }
353         if ($model->is_static()) {
354             $model->mark_as_trained();
355         }
357         return $model;
358     }
360     /**
361      * Does this model exist?
362      *
363      * If no indicators are provided it considers any model with the provided
364      * target a match.
365      *
366      * @param \core_analytics\local\target\base $target
367      * @param \core_analytics\local\indicator\base[]|false $indicators
368      * @return bool
369      */
370     public static function exists(\core_analytics\local\target\base $target, $indicators = false) {
371         global $DB;
373         $existingmodels = $DB->get_records('analytics_models', array('target' => $target->get_id()));
375         if (!$indicators && $existingmodels) {
376             return true;
377         }
379         $indicatorids = array_keys($indicators);
380         sort($indicatorids);
382         foreach ($existingmodels as $modelobj) {
383             $model = new \core_analytics\model($modelobj);
384             $modelindicatorids = array_keys($model->get_indicators());
385             sort($modelindicatorids);
387             if ($indicatorids === $modelindicatorids) {
388                 return true;
389             }
390         }
391         return false;
392     }
394     /**
395      * Updates the model.
396      *
397      * @param int|bool $enabled
398      * @param \core_analytics\local\indicator\base[] $indicators
399      * @param string $timesplittingid
400      * @return void
401      */
402     public function update($enabled, $indicators, $timesplittingid = '') {
403         global $USER, $DB;
405         \core_analytics\manager::check_can_manage_models();
407         $now = time();
409         $indicatorclasses = self::indicator_classes($indicators);
411         $indicatorsstr = json_encode($indicatorclasses);
412         if ($this->model->timesplitting !== $timesplittingid ||
413                 $this->model->indicators !== $indicatorsstr) {
414             // We update the version of the model so different time splittings are not mixed up.
415             $this->model->version = $now;
417             // Delete generated predictions.
418             $this->clear_model();
420             // Purge all generated files.
421             \core_analytics\dataset_manager::clear_model_files($this->model->id);
423             // Reset trained flag.
424             $this->model->trained = 0;
425         }
426         $this->model->enabled = intval($enabled);
427         $this->model->indicators = $indicatorsstr;
428         $this->model->timesplitting = $timesplittingid;
429         $this->model->timemodified = $now;
430         $this->model->usermodified = $USER->id;
432         $DB->update_record('analytics_models', $this->model);
434         // It needs to be reset (just in case, we may already used it).
435         $this->uniqueid = null;
436     }
438     /**
439      * Removes the model.
440      *
441      * @return void
442      */
443     public function delete() {
444         global $DB;
446         \core_analytics\manager::check_can_manage_models();
448         $this->clear_model();
449         $DB->delete_records('analytics_models', array('id' => $this->model->id));
450     }
452     /**
453      * Evaluates the model.
454      *
455      * This method gets the site contents (through the analyser) creates a .csv dataset
456      * with them and evaluates the model prediction accuracy multiple times using the
457      * machine learning backend. It returns an object where the model score is the average
458      * prediction accuracy of all executed evaluations.
459      *
460      * @param array $options
461      * @return \stdClass[]
462      */
463     public function evaluate($options = array()) {
465         \core_analytics\manager::check_can_manage_models();
467         if ($this->is_static()) {
468             $this->get_analyser()->add_log(get_string('noevaluationbasedassumptions', 'analytics'));
469             $result = new \stdClass();
470             $result->status = self::OK;
471             return $result;
472         }
474         $options['evaluation'] = true;
475         $this->init_analyser($options);
477         if (empty($this->get_indicators())) {
478             throw new \moodle_exception('errornoindicators', 'analytics');
479         }
481         $this->heavy_duty_mode();
483         // Before get_labelled_data call so we get an early exception if it is not ready.
484         $predictor = \core_analytics\manager::get_predictions_processor();
486         $datasets = $this->get_analyser()->get_labelled_data();
488         // No datasets generated.
489         if (empty($datasets)) {
490             $result = new \stdClass();
491             $result->status = self::NO_DATASET;
492             $result->info = $this->get_analyser()->get_logs();
493             return array($result);
494         }
496         if (!PHPUNIT_TEST && CLI_SCRIPT) {
497             echo PHP_EOL . get_string('processingsitecontents', 'analytics') . PHP_EOL;
498         }
500         $results = array();
501         foreach ($datasets as $timesplittingid => $dataset) {
503             $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingid);
505             $result = new \stdClass();
507             $dashestimesplittingid = str_replace('\\', '', $timesplittingid);
508             $outputdir = $this->get_output_dir(array('evaluation', $dashestimesplittingid));
510             // Evaluate the dataset, the deviation we accept in the results depends on the amount of iterations.
511             $predictorresult = $predictor->evaluate($this->model->id, self::ACCEPTED_DEVIATION,
512                 self::EVALUATION_ITERATIONS, $dataset, $outputdir);
514             $result->status = $predictorresult->status;
515             $result->info = $predictorresult->info;
517             if (isset($predictorresult->score)) {
518                 $result->score = $predictorresult->score;
519             } else {
520                 // Prediction processors may return an error, default to 0 score in that case.
521                 $result->score = 0;
522             }
524             $dir = false;
525             if (!empty($predictorresult->dir)) {
526                 $dir = $predictorresult->dir;
527             }
529             $result->logid = $this->log_result($timesplitting->get_id(), $result->score, $dir, $result->info);
531             $results[$timesplitting->get_id()] = $result;
532         }
534         return $results;
535     }
537     /**
538      * Trains the model using the site contents.
539      *
540      * This method prepares a dataset from the site contents (through the analyser)
541      * and passes it to the machine learning backends. Static models are skipped as
542      * they do not require training.
543      *
544      * @return \stdClass
545      */
546     public function train() {
548         \core_analytics\manager::check_can_manage_models();
550         if ($this->is_static()) {
551             $this->get_analyser()->add_log(get_string('notrainingbasedassumptions', 'analytics'));
552             $result = new \stdClass();
553             $result->status = self::OK;
554             return $result;
555         }
557         if (!$this->is_enabled() || empty($this->model->timesplitting)) {
558             throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
559         }
561         if (empty($this->get_indicators())) {
562             throw new \moodle_exception('errornoindicators', 'analytics');
563         }
565         $this->heavy_duty_mode();
567         // Before get_labelled_data call so we get an early exception if it is not writable.
568         $outputdir = $this->get_output_dir(array('execution'));
570         // Before get_labelled_data call so we get an early exception if it is not ready.
571         $predictor = \core_analytics\manager::get_predictions_processor();
573         $datasets = $this->get_analyser()->get_labelled_data();
575         // No training if no files have been provided.
576         if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
578             $result = new \stdClass();
579             $result->status = self::NO_DATASET;
580             $result->info = $this->get_analyser()->get_logs();
581             return $result;
582         }
583         $samplesfile = $datasets[$this->model->timesplitting];
585         // Train using the dataset.
586         $predictorresult = $predictor->train($this->get_unique_id(), $samplesfile, $outputdir);
588         $result = new \stdClass();
589         $result->status = $predictorresult->status;
590         $result->info = $predictorresult->info;
592         $this->flag_file_as_used($samplesfile, 'trained');
594         // Mark the model as trained if it wasn't.
595         if ($this->model->trained == false) {
596             $this->mark_as_trained();
597         }
599         return $result;
600     }
602     /**
603      * Get predictions from the site contents.
604      *
605      * It analyses the site contents (through analyser classes) looking for samples
606      * ready to receive predictions. It generates a dataset with all samples ready to
607      * get predictions and it passes it to the machine learning backends or to the
608      * targets based on assumptions to get the predictions.
609      *
610      * @return \stdClass
611      */
612     public function predict() {
613         global $DB;
615         \core_analytics\manager::check_can_manage_models();
617         if (!$this->is_enabled() || empty($this->model->timesplitting)) {
618             throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
619         }
621         if (empty($this->get_indicators())) {
622             throw new \moodle_exception('errornoindicators', 'analytics');
623         }
625         $this->heavy_duty_mode();
627         // Before get_unlabelled_data call so we get an early exception if it is not writable.
628         $outputdir = $this->get_output_dir(array('execution'));
630         // Before get_unlabelled_data call so we get an early exception if it is not ready.
631         if (!$this->is_static()) {
632             $predictor = \core_analytics\manager::get_predictions_processor();
633         }
635         $samplesdata = $this->get_analyser()->get_unlabelled_data();
637         // Get the prediction samples file.
638         if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
640             $result = new \stdClass();
641             $result->status = self::NO_DATASET;
642             $result->info = $this->get_analyser()->get_logs();
643             return $result;
644         }
645         $samplesfile = $samplesdata[$this->model->timesplitting];
647         // We need to throw an exception if we are trying to predict stuff that was already predicted.
648         $params = array('modelid' => $this->model->id, 'fileid' => $samplesfile->get_id(), 'action' => 'predicted');
649         if ($predicted = $DB->get_record('analytics_used_files', $params)) {
650             throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id());
651         }
653         $indicatorcalculations = \core_analytics\dataset_manager::get_structured_data($samplesfile);
655         // Prepare the results object.
656         $result = new \stdClass();
658         if ($this->is_static()) {
659             // Prediction based on assumptions.
660             $result->status = self::OK;
661             $result->info = [];
662             $result->predictions = $this->get_static_predictions($indicatorcalculations);
664         } else {
665             // Prediction process runs on the machine learning backend.
666             $predictorresult = $predictor->predict($this->get_unique_id(), $samplesfile, $outputdir);
667             $result->status = $predictorresult->status;
668             $result->info = $predictorresult->info;
669             $result->predictions = $this->format_predictor_predictions($predictorresult);
670         }
672         if ($result->predictions) {
673             $samplecontexts = $this->execute_prediction_callbacks($result->predictions, $indicatorcalculations);
674         }
676         if (!empty($samplecontexts) && $this->uses_insights()) {
677             $this->trigger_insights($samplecontexts);
678         }
680         $this->flag_file_as_used($samplesfile, 'predicted');
682         return $result;
683     }
685     /**
686      * Formats the predictor results.
687      *
688      * @param array $predictorresult
689      * @return array
690      */
691     private function format_predictor_predictions($predictorresult) {
693         $predictions = array();
694         if ($predictorresult->predictions) {
695             foreach ($predictorresult->predictions as $sampleinfo) {
697                 // We parse each prediction.
698                 switch (count($sampleinfo)) {
699                     case 1:
700                         // For whatever reason the predictions processor could not process this sample, we
701                         // skip it and do nothing with it.
702                         debugging($this->model->id . ' model predictions processor could not process the sample with id ' .
703                             $sampleinfo[0], DEBUG_DEVELOPER);
704                         continue;
705                     case 2:
706                         // Prediction processors that do not return a prediction score will have the maximum prediction
707                         // score.
708                         list($uniquesampleid, $prediction) = $sampleinfo;
709                         $predictionscore = 1;
710                         break;
711                     case 3:
712                         list($uniquesampleid, $prediction, $predictionscore) = $sampleinfo;
713                         break;
714                     default:
715                         break;
716                 }
717                 $predictiondata = (object)['prediction' => $prediction, 'predictionscore' => $predictionscore];
718                 $predictions[$uniquesampleid] = $predictiondata;
719             }
720         }
721         return $predictions;
722     }
724     /**
725      * Execute the prediction callbacks defined by the target.
726      *
727      * @param \stdClass[] $predictions
728      * @param array $indicatorcalculations
729      * @return array
730      */
731     protected function execute_prediction_callbacks($predictions, $indicatorcalculations) {
733         // Here we will store all predictions' contexts, this will be used to limit which users will see those predictions.
734         $samplecontexts = array();
736         foreach ($predictions as $uniquesampleid => $prediction) {
738             if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
740                 // The unique sample id contains both the sampleid and the rangeindex.
741                 list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
743                 // Store the predicted values.
744                 $samplecontext = $this->save_prediction($sampleid, $rangeindex, $prediction->prediction,
745                     $prediction->predictionscore, json_encode($indicatorcalculations[$uniquesampleid]));
747                 // Also store all samples context to later generate insights or whatever action the target wants to perform.
748                 $samplecontexts[$samplecontext->id] = $samplecontext;
750                 $this->get_target()->prediction_callback($this->model->id, $sampleid, $rangeindex, $samplecontext,
751                     $prediction->prediction, $prediction->predictionscore);
752             }
753         }
755         return $samplecontexts;
756     }
758     /**
759      * Generates insights and updates the cache.
760      *
761      * @param \context[] $samplecontexts
762      * @return void
763      */
764     protected function trigger_insights($samplecontexts) {
766         // Notify the target that all predictions have been processed.
767         $this->get_target()->generate_insight_notifications($this->model->id, $samplecontexts);
769         // Update cache.
770         $cache = \cache::make('core', 'contextwithinsights');
771         foreach ($samplecontexts as $context) {
772             $modelids = $cache->get($context->id);
773             if (!$modelids) {
774                 // The cache is empty, but we don't know if it is empty because there are no insights
775                 // in this context or because cache/s have been purged, we need to be conservative and
776                 // "pay" 1 db read to fill up the cache.
777                 $models = \core_analytics\manager::get_models_with_insights($context);
778                 $cache->set($context->id, array_keys($models));
779             } else if (!in_array($this->get_id(), $modelids)) {
780                 array_push($modelids, $this->get_id());
781                 $cache->set($context->id, $modelids);
782             }
783         }
784     }
786     /**
787      * Get predictions from a static model.
788      *
789      * @param array $indicatorcalculations
790      * @return \stdClass[]
791      */
792     protected function get_static_predictions(&$indicatorcalculations) {
794         // Group samples by analysable for \core_analytics\local\target::calculate.
795         $analysables = array();
796         // List all sampleids together.
797         $sampleids = array();
799         foreach ($indicatorcalculations as $uniquesampleid => $indicators) {
800             list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
802             $analysable = $this->get_analyser()->get_sample_analysable($sampleid);
803             $analysableclass = get_class($analysable);
804             if (empty($analysables[$analysableclass])) {
805                 $analysables[$analysableclass] = array();
806             }
807             if (empty($analysables[$analysableclass][$rangeindex])) {
808                 $analysables[$analysableclass][$rangeindex] = (object)[
809                     'analysable' => $analysable,
810                     'indicatorsdata' => array(),
811                     'sampleids' => array()
812                 ];
813             }
814             // Using the sampleid as a key so we can easily merge indicators data later.
815             $analysables[$analysableclass][$rangeindex]->indicatorsdata[$sampleid] = $indicators;
816             // We could use indicatorsdata keys but the amount of redundant data is not that big and leaves code below cleaner.
817             $analysables[$analysableclass][$rangeindex]->sampleids[$sampleid] = $sampleid;
819             // Accumulate sample ids to get all their associated data in 1 single db query (analyser::get_samples).
820             $sampleids[$sampleid] = $sampleid;
821         }
823         // Get all samples data.
824         list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
826         // Calculate the targets.
827         $predictions = array();
828         foreach ($analysables as $analysableclass => $rangedata) {
829             foreach ($rangedata as $rangeindex => $data) {
831                 // Attach samples data and calculated indicators data.
832                 $this->get_target()->clear_sample_data();
833                 $this->get_target()->add_sample_data($samplesdata);
834                 $this->get_target()->add_sample_data($data->indicatorsdata);
836                 // Append new elements (we can not get duplicates because sample-analysable relation is N-1).
837                 $range = $this->get_time_splitting()->get_range_by_index($rangeindex);
838                 $this->get_target()->filter_out_invalid_samples($data->sampleids, $data->analysable, false);
839                 $calculations = $this->get_target()->calculate($data->sampleids, $data->analysable, $range['start'], $range['end']);
841                 // Missing $indicatorcalculations values in $calculations are caused by is_valid_sample. We need to remove
842                 // these $uniquesampleid from $indicatorcalculations because otherwise they will be stored as calculated
843                 // by self::save_prediction.
844                 $indicatorcalculations = array_filter($indicatorcalculations, function($indicators, $uniquesampleid) use ($calculations) {
845                     list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
846                     if (!isset($calculations[$sampleid])) {
847                         return false;
848                     }
849                     return true;
850                 }, ARRAY_FILTER_USE_BOTH);
852                 foreach ($calculations as $sampleid => $value) {
854                     $uniquesampleid = $this->get_time_splitting()->append_rangeindex($sampleid, $rangeindex);
856                     // Null means that the target couldn't calculate the sample, we also remove them from $indicatorcalculations.
857                     if (is_null($calculations[$sampleid])) {
858                         unset($indicatorcalculations[$uniquesampleid]);
859                         continue;
860                     }
862                     // Even if static predictions are based on assumptions we flag them as 100% because they are 100%
863                     // true according to what the developer defined.
864                     $predictions[$uniquesampleid] = (object)['prediction' => $value, 'predictionscore' => 1];
865                 }
866             }
867         }
868         return $predictions;
869     }
871     /**
872      * Stores the prediction in the database.
873      *
874      * @param int $sampleid
875      * @param int $rangeindex
876      * @param int $prediction
877      * @param float $predictionscore
878      * @param string $calculations
879      * @return \context
880      */
881     protected function save_prediction($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) {
882         global $DB;
884         $context = $this->get_analyser()->sample_access_context($sampleid);
886         $record = new \stdClass();
887         $record->modelid = $this->model->id;
888         $record->contextid = $context->id;
889         $record->sampleid = $sampleid;
890         $record->rangeindex = $rangeindex;
891         $record->prediction = $prediction;
892         $record->predictionscore = $predictionscore;
893         $record->calculations = $calculations;
894         $record->timecreated = time();
895         $DB->insert_record('analytics_predictions', $record);
897         return $context;
898     }
900     /**
901      * Enabled the model using the provided time splitting method.
902      *
903      * @param string $timesplittingid
904      * @return void
905      */
906     public function enable($timesplittingid = false) {
907         global $DB;
909         \core_analytics\manager::check_can_manage_models();
911         $now = time();
913         if ($timesplittingid && $timesplittingid !== $this->model->timesplitting) {
915             if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) {
916                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
917             }
919             if (substr($timesplittingid, 0, 1) !== '\\') {
920                 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
921             }
923             $this->model->timesplitting = $timesplittingid;
924             $this->model->version = $now;
925         }
926         $this->model->enabled = 1;
927         $this->model->timemodified = $now;
929         // We don't always update timemodified intentionally as we reserve it for target, indicators or timesplitting updates.
930         $DB->update_record('analytics_models', $this->model);
932         // It needs to be reset (just in case, we may already used it).
933         $this->uniqueid = null;
934     }
936     /**
937      * Is this a static model (as defined by the target)?.
938      *
939      * Static models are based on assumptions instead of in machine learning
940      * backends results.
941      *
942      * @return bool
943      */
944     public function is_static() {
945         return (bool)$this->get_target()->based_on_assumptions();
946     }
948     /**
949      * Is this model enabled?
950      *
951      * @return bool
952      */
953     public function is_enabled() {
954         return (bool)$this->model->enabled;
955     }
957     /**
958      * Is this model already trained?
959      *
960      * @return bool
961      */
962     public function is_trained() {
963         // Models which targets are based on assumptions do not need training.
964         return (bool)$this->model->trained || $this->is_static();
965     }
967     /**
968      * Marks the model as trained
969      *
970      * @return void
971      */
972     public function mark_as_trained() {
973         global $DB;
975         \core_analytics\manager::check_can_manage_models();
977         $this->model->trained = 1;
978         $DB->update_record('analytics_models', $this->model);
979     }
981     /**
982      * Get the contexts with predictions.
983      *
984      * @return \stdClass[]
985      */
986     public function get_predictions_contexts() {
987         global $DB;
989         $sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
990                   JOIN {context} ctx ON ctx.id = ap.contextid
991                  WHERE ap.modelid = ?";
992         return $DB->get_records_sql($sql, array($this->model->id));
993     }
995     /**
996      * Has this model generated predictions?
997      *
998      * We don't check analytics_predictions table because targets have the ability to
999      * ignore some predicted values, if that is the case predictions are not even stored
1000      * in db.
1001      *
1002      * @return bool
1003      */
1004     public function any_prediction_obtained() {
1005         global $DB;
1006         return $DB->record_exists('analytics_predict_ranges',
1007             array('modelid' => $this->model->id, 'timesplitting' => $this->model->timesplitting));
1008     }
1010     /**
1011      * Whether this model generates insights or not (defined by the model's target).
1012      *
1013      * @return bool
1014      */
1015     public function uses_insights() {
1016         $target = $this->get_target();
1017         return $target::uses_insights();
1018     }
1020     /**
1021      * Whether predictions exist for this context.
1022      *
1023      * @param \context $context
1024      * @return bool
1025      */
1026     public function predictions_exist(\context $context) {
1027         global $DB;
1029         // Filters out previous predictions keeping only the last time range one.
1030         $select = "modelid = :modelid AND contextid = :contextid";
1031         $params = array('modelid' => $this->model->id, 'contextid' => $context->id);
1032         return $DB->record_exists_select('analytics_predictions', $select, $params);
1033     }
1035     /**
1036      * Gets the predictions for this context.
1037      *
1038      * @param \context $context
1039      * @param int $page The page of results to fetch. False for all results.
1040      * @param int $perpage The max number of results to fetch. Ignored if $page is false.
1041      * @return array($total, \core_analytics\prediction[])
1042      */
1043     public function get_predictions(\context $context, $page = false, $perpage = 100) {
1044         global $DB;
1046         \core_analytics\manager::check_can_list_insights($context);
1048         // Filters out previous predictions keeping only the last time range one.
1049         $sql = "SELECT ap.*
1050                   FROM {analytics_predictions} ap
1051                   JOIN (
1052                     SELECT sampleid, max(rangeindex) AS rangeindex
1053                       FROM {analytics_predictions}
1054                      WHERE modelid = ? and contextid = ?
1055                     GROUP BY sampleid
1056                   ) apsub
1057                   ON ap.sampleid = apsub.sampleid AND ap.rangeindex = apsub.rangeindex
1058                  WHERE ap.modelid = ? and ap.contextid = ?";
1059         $params = array($this->model->id, $context->id, $this->model->id, $context->id);
1060         if (!$predictions = $DB->get_records_sql($sql, $params)) {
1061             return array();
1062         }
1064         // Get predicted samples' ids.
1065         $sampleids = array_map(function($prediction) {
1066             return $prediction->sampleid;
1067         }, $predictions);
1069         list($unused, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
1071         $current = 0;
1073         if ($page !== false) {
1074             $offset = $page * $perpage;
1075             $limit = $offset + $perpage;
1076         }
1078         foreach ($predictions as $predictionid => $predictiondata) {
1080             $sampleid = $predictiondata->sampleid;
1082             // Filter out predictions which samples are not available anymore.
1083             if (empty($samplesdata[$sampleid])) {
1084                 unset($predictions[$predictionid]);
1085                 continue;
1086             }
1088             // Return paginated dataset - we cannot paginate in the DB because we post filter the list.
1089             if ($page === false || ($current >= $offset && $current < $limit)) {
1090                 // Replace \stdClass object by \core_analytics\prediction objects.
1091                 $prediction = new \core_analytics\prediction($predictiondata, $samplesdata[$sampleid]);
1092                 $predictions[$predictionid] = $prediction;
1093             } else {
1094                 unset($predictions[$predictionid]);
1095             }
1097             $current++;
1098         }
1100         return [$current, $predictions];
1101     }
1103     /**
1104      * Returns the sample data of a prediction.
1105      *
1106      * @param \stdClass $predictionobj
1107      * @return array
1108      */
1109     public function prediction_sample_data($predictionobj) {
1111         list($unused, $samplesdata) = $this->get_analyser()->get_samples(array($predictionobj->sampleid));
1113         if (empty($samplesdata[$predictionobj->sampleid])) {
1114             throw new \moodle_exception('errorsamplenotavailable', 'analytics');
1115         }
1117         return $samplesdata[$predictionobj->sampleid];
1118     }
1120     /**
1121      * Returns the description of a sample
1122      *
1123      * @param \core_analytics\prediction $prediction
1124      * @return array 2 elements: list(string, \renderable)
1125      */
1126     public function prediction_sample_description(\core_analytics\prediction $prediction) {
1127         return $this->get_analyser()->sample_description($prediction->get_prediction_data()->sampleid,
1128             $prediction->get_prediction_data()->contextid, $prediction->get_sample_data());
1129     }
1131     /**
1132      * Returns the output directory for prediction processors.
1133      *
1134      * Directory structure as follows:
1135      * - Evaluation runs:
1136      *   models/$model->id/$model->version/evaluation/$model->timesplitting
1137      * - Training  & prediction runs:
1138      *   models/$model->id/$model->version/execution
1139      *
1140      * @param array $subdirs
1141      * @return string
1142      */
1143     protected function get_output_dir($subdirs = array()) {
1144         global $CFG;
1146         $subdirstr = '';
1147         foreach ($subdirs as $subdir) {
1148             $subdirstr .= DIRECTORY_SEPARATOR . $subdir;
1149         }
1151         $outputdir = get_config('analytics', 'modeloutputdir');
1152         if (empty($outputdir)) {
1153             // Apply default value.
1154             $outputdir = rtrim($CFG->dataroot, '/') . DIRECTORY_SEPARATOR . 'models';
1155         }
1157         // Append model id and version + subdirs.
1158         $outputdir .= DIRECTORY_SEPARATOR . $this->model->id . DIRECTORY_SEPARATOR . $this->model->version . $subdirstr;
1160         make_writable_directory($outputdir);
1162         return $outputdir;
1163     }
1165     /**
1166      * Returns a unique id for this model.
1167      *
1168      * This id should be unique for this site.
1169      *
1170      * @return string
1171      */
1172     public function get_unique_id() {
1173         global $CFG;
1175         if (!is_null($this->uniqueid)) {
1176             return $this->uniqueid;
1177         }
1179         // Generate a unique id for this site, this model and this time splitting method, considering the last time
1180         // that the model target and indicators were updated.
1181         $ids = array($CFG->wwwroot, $CFG->dirroot, $CFG->prefix, $this->model->id, $this->model->version);
1182         $this->uniqueid = sha1(implode('$$', $ids));
1184         return $this->uniqueid;
1185     }
1187     /**
1188      * Exports the model data.
1189      *
1190      * @return \stdClass
1191      */
1192     public function export() {
1194         \core_analytics\manager::check_can_manage_models();
1196         $data = clone $this->model;
1197         $data->target = $this->get_target()->get_name();
1199         if ($timesplitting = $this->get_time_splitting()) {
1200             $data->timesplitting = $timesplitting->get_name();
1201         }
1203         $data->indicators = array();
1204         foreach ($this->get_indicators() as $indicator) {
1205             $data->indicators[] = $indicator->get_name();
1206         }
1207         return $data;
1208     }
1210     /**
1211      * Returns the model logs data.
1212      *
1213      * @param int $limitfrom
1214      * @param int $limitnum
1215      * @return \stdClass[]
1216      */
1217     public function get_logs($limitfrom = 0, $limitnum = 0) {
1218         global $DB;
1220         \core_analytics\manager::check_can_manage_models();
1222         return $DB->get_records('analytics_models_log', array('modelid' => $this->get_id()), 'timecreated DESC', '*',
1223             $limitfrom, $limitnum);
1224     }
1226     /**
1227      * Flag the provided file as used for training or prediction.
1228      *
1229      * @param \stored_file $file
1230      * @param string $action
1231      * @return void
1232      */
1233     protected function flag_file_as_used(\stored_file $file, $action) {
1234         global $DB;
1236         $usedfile = new \stdClass();
1237         $usedfile->modelid = $this->model->id;
1238         $usedfile->fileid = $file->get_id();
1239         $usedfile->action = $action;
1240         $usedfile->time = time();
1241         $DB->insert_record('analytics_used_files', $usedfile);
1242     }
1244     /**
1245      * Log the evaluation results in the database.
1246      *
1247      * @param string $timesplittingid
1248      * @param float $score
1249      * @param string $dir
1250      * @param array $info
1251      * @return int The inserted log id
1252      */
1253     protected function log_result($timesplittingid, $score, $dir = false, $info = false) {
1254         global $DB, $USER;
1256         $log = new \stdClass();
1257         $log->modelid = $this->get_id();
1258         $log->version = $this->model->version;
1259         $log->target = $this->model->target;
1260         $log->indicators = $this->model->indicators;
1261         $log->timesplitting = $timesplittingid;
1262         $log->dir = $dir;
1263         if ($info) {
1264             // Ensure it is not an associative array.
1265             $log->info = json_encode(array_values($info));
1266         }
1267         $log->score = $score;
1268         $log->timecreated = time();
1269         $log->usermodified = $USER->id;
1271         return $DB->insert_record('analytics_models_log', $log);
1272     }
1274     /**
1275      * Utility method to return indicator class names from a list of indicator objects
1276      *
1277      * @param \core_analytics\local\indicator\base[] $indicators
1278      * @return string[]
1279      */
1280     private static function indicator_classes($indicators) {
1282         // What we want to check and store are the indicator classes not the keys.
1283         $indicatorclasses = array();
1284         foreach ($indicators as $indicator) {
1285             if (!\core_analytics\manager::is_valid($indicator, '\core_analytics\local\indicator\base')) {
1286                 if (!is_object($indicator) && !is_scalar($indicator)) {
1287                     $indicator = strval($indicator);
1288                 } else if (is_object($indicator)) {
1289                     $indicator = '\\' . get_class($indicator);
1290                 }
1291                 throw new \moodle_exception('errorinvalidindicator', 'analytics', '', $indicator);
1292             }
1293             $indicatorclasses[] = $indicator->get_id();
1294         }
1296         return $indicatorclasses;
1297     }
1299     /**
1300      * Clears the model training and prediction data.
1301      *
1302      * Executed after updating model critical elements like the time splitting method
1303      * or the indicators.
1304      *
1305      * @return void
1306      */
1307     private function clear_model() {
1308         global $DB;
1310         $DB->delete_records('analytics_predict_ranges', array('modelid' => $this->model->id));
1311         $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id));
1312         $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
1313         $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
1315         // We don't expect people to clear models regularly and the cost of filling the cache is
1316         // 1 db read per context.
1317         $cache = \cache::make('core', 'contextwithinsights');
1318         $cache->purge();
1319     }
1321     /**
1322      * Increases system memory and time limits.
1323      *
1324      * @return void
1325      */
1326     private function heavy_duty_mode() {
1327         if (ini_get('memory_limit') != -1) {
1328             raise_memory_limit(MEMORY_HUGE);
1329         }
1330         \core_php_time_limit::raise();
1331     }