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