MDL-59057 analytics: no_teaching model fixes
[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
38 const OK = 0;
39 const GENERAL_ERROR = 1;
40 const NO_DATASET = 2;
41
42 const EVALUATE_LOW_SCORE = 4;
43 const EVALUATE_NOT_ENOUGH_DATA = 8;
44
45 const ANALYSE_INPROGRESS = 2;
46 const ANALYSE_REJECTED_RANGE_PROCESSOR = 4;
47 const ANALYSABLE_STATUS_INVALID_FOR_RANGEPROCESSORS = 8;
48 const ANALYSABLE_STATUS_INVALID_FOR_TARGET = 16;
49
50 const MIN_SCORE = 0.7;
51 const ACCEPTED_DEVIATION = 0.05;
52 const EVALUATION_ITERATIONS = 10;
53
54 /**
55 * @var \stdClass
56 */
57 protected $model = null;
58
59 /**
60 * @var \core_analytics\local\analyser\base
61 */
62 protected $analyser = null;
63
64 /**
65 * @var \core_analytics\local\target\base
66 */
67 protected $target = null;
68
69 /**
70 * @var \core_analytics\local\indicator\base[]
71 */
72 protected $indicators = null;
73
74 /**
75 * Unique Model id created from site info and last model modification.
76 *
77 * @var string
78 */
79 protected $uniqueid = null;
80
81 /**
82 * __construct
83 *
84 * @param int|stdClass $model
85 * @return void
86 */
87 public function __construct($model) {
88 global $DB;
89
90 if (is_scalar($model)) {
91 $model = $DB->get_record('analytics_models', array('id' => $model));
92 }
93 $this->model = $model;
94 }
95
96 /**
97 * get_id
98 *
99 * @return int
100 */
101 public function get_id() {
102 return $this->model->id;
103 }
104
105 /**
106 * get_model_obj
107 *
108 * @return \stdClass
109 */
110 public function get_model_obj() {
111 return $this->model;
112 }
113
114 /**
115 * get_target
116 *
117 * @return \core_analytics\local\target\base
118 */
119 public function get_target() {
120 if ($this->target !== null) {
121 return $this->target;
122 }
123 $instance = \core_analytics\manager::get_target($this->model->target);
124 $this->target = $instance;
125
126 return $this->target;
127 }
128
129 /**
130 * get_indicators
131 *
132 * @return \core_analytics\local\indicator\base[]
133 */
134 public function get_indicators() {
135 if ($this->indicators !== null) {
136 return $this->indicators;
137 }
138
139 $fullclassnames = json_decode($this->model->indicators);
140
141 if (!is_array($fullclassnames)) {
142 throw new \coding_exception('Model ' . $this->model->id . ' indicators can not be read');
143 }
144
145 $this->indicators = array();
146 foreach ($fullclassnames as $fullclassname) {
147 $instance = \core_analytics\manager::get_indicator($fullclassname);
148 if ($instance) {
149 $this->indicators[$fullclassname] = $instance;
150 } else {
151 debugging('Can\'t load ' . $fullclassname . ' indicator', DEBUG_DEVELOPER);
152 }
153 }
154
155 return $this->indicators;
156 }
157
158 /**
159 * Returns the list of indicators that could potentially be used by the model target.
160 *
161 * It includes the indicators that are part of the model.
162 *
a40952d3 163 * @return \core_analytics\local\indicator\base[]
369389c9
DM
164 */
165 public function get_potential_indicators() {
166
167 $indicators = \core_analytics\manager::get_all_indicators();
168
169 if (empty($this->analyser)) {
170 $this->init_analyser(array('evaluation' => true));
171 }
172
173 foreach ($indicators as $classname => $indicator) {
174 if ($this->analyser->check_indicator_requirements($indicator) !== true) {
175 unset($indicators[$classname]);
176 }
177 }
178 return $indicators;
179 }
180
181 /**
182 * get_analyser
183 *
184 * @return \core_analytics\local\analyser\base
185 */
186 public function get_analyser() {
187 if ($this->analyser !== null) {
188 return $this->analyser;
189 }
190
191 // Default initialisation with no options.
192 $this->init_analyser();
193
194 return $this->analyser;
195 }
196
197 /**
198 * init_analyser
199 *
200 * @param array $options
201 * @return void
202 */
203 protected function init_analyser($options = array()) {
204
205 $target = $this->get_target();
206 $indicators = $this->get_indicators();
207
208 if (empty($target)) {
209 throw new \moodle_exception('errornotarget', 'analytics');
210 }
211
212 if (!empty($options['evaluation'])) {
213 // The evaluation process will run using all available time splitting methods unless one is specified.
214 if (!empty($options['timesplitting'])) {
215 $timesplitting = \core_analytics\manager::get_time_splitting($options['timesplitting']);
216 $timesplittings = array($timesplitting->get_id() => $timesplitting);
217 } else {
218 $timesplittings = \core_analytics\manager::get_enabled_time_splitting_methods();
219 }
220 } else {
221
222 if (empty($this->model->timesplitting)) {
223 throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
224 }
225
226 // Returned as an array as all actions (evaluation, training and prediction) go through the same process.
227 $timesplittings = array($this->model->timesplitting => $this->get_time_splitting());
228 }
229
230 if (empty($timesplittings)) {
231 throw new \moodle_exception('errornotimesplittings', 'analytics');
232 }
233
234 $classname = $target->get_analyser_class();
235 if (!class_exists($classname)) {
236 throw \coding_exception($classname . ' class does not exists');
237 }
238
239 // Returns a \core_analytics\local\analyser\base class.
240 $this->analyser = new $classname($this->model->id, $target, $indicators, $timesplittings, $options);
241 }
242
243 /**
244 * get_time_splitting
245 *
246 * @return \core_analytics\local\time_splitting\base
247 */
248 public function get_time_splitting() {
249 if (empty($this->model->timesplitting)) {
250 return false;
251 }
252 return \core_analytics\manager::get_time_splitting($this->model->timesplitting);
253 }
254
255 /**
a40952d3 256 * Creates a new model. Enables it if $timesplittingid is specified.
369389c9
DM
257 *
258 * @param \core_analytics\local\target\base $target
259 * @param \core_analytics\local\indicator\base[] $indicators
a40952d3 260 * @param string $timesplittingid The time splitting method id (its fully qualified class name)
369389c9
DM
261 * @return \core_analytics\model
262 */
a40952d3 263 public static function create(\core_analytics\local\target\base $target, array $indicators, $timesplittingid = false) {
369389c9
DM
264 global $USER, $DB;
265
266 $indicatorclasses = self::indicator_classes($indicators);
267
268 $now = time();
269
270 $modelobj = new \stdClass();
b0c24929 271 $modelobj->target = $target->get_id();
369389c9
DM
272 $modelobj->indicators = json_encode($indicatorclasses);
273 $modelobj->version = $now;
274 $modelobj->timecreated = $now;
275 $modelobj->timemodified = $now;
276 $modelobj->usermodified = $USER->id;
277
278 $id = $DB->insert_record('analytics_models', $modelobj);
279
280 // Get db defaults.
281 $modelobj = $DB->get_record('analytics_models', array('id' => $id), '*', MUST_EXIST);
282
a40952d3
DM
283 $model = new static($modelobj);
284
285 if ($timesplittingid) {
286 $model->enable($timesplittingid);
287 }
288
289 if ($model->is_static()) {
290 $model->mark_as_trained();
291 }
292
293 return $model;
369389c9
DM
294 }
295
a40952d3
DM
296 /**
297 * update
298 *
299 * @param int|bool $enabled
300 * @param \core_analytics\local\indicator\base[] $indicators
301 * @param string $timesplittingid
302 * @return void
303 */
304 public function update($enabled, $indicators, $timesplittingid = '') {
369389c9
DM
305 global $USER, $DB;
306
307 $now = time();
308
309 $indicatorclasses = self::indicator_classes($indicators);
310
311 $indicatorsstr = json_encode($indicatorclasses);
a40952d3 312 if ($this->model->timesplitting !== $timesplittingid ||
369389c9
DM
313 $this->model->indicators !== $indicatorsstr) {
314 // We update the version of the model so different time splittings are not mixed up.
315 $this->model->version = $now;
316
317 // Delete generated predictions.
318 $this->clear_model();
319
320 // Purge all generated files.
321 \core_analytics\dataset_manager::clear_model_files($this->model->id);
322
323 // Reset trained flag.
324 $this->model->trained = 0;
325 }
a40952d3 326 $this->model->enabled = intval($enabled);
369389c9 327 $this->model->indicators = $indicatorsstr;
a40952d3 328 $this->model->timesplitting = $timesplittingid;
369389c9
DM
329 $this->model->timemodified = $now;
330 $this->model->usermodified = $USER->id;
331
332 $DB->update_record('analytics_models', $this->model);
333
334 // It needs to be reset (just in case, we may already used it).
335 $this->uniqueid = null;
336 }
337
d16cf374
DM
338 /**
339 * Removes the model.
340 *
341 * @return void
342 */
343 public static function delete() {
344 global $DB;
345 $this->clear_model();
346 $DB->delete_record('analytics_models', array('id' => $this->model->id));
347 }
348
369389c9
DM
349 /**
350 * Evaluates the model datasets.
351 *
352 * Model datasets should already be available in Moodle's filesystem.
353 *
354 * @param array $options
355 * @return \stdClass[]
356 */
357 public function evaluate($options = array()) {
358
a40952d3
DM
359 if ($this->is_static()) {
360 $this->get_analyser()->add_log(get_string('noevaluationbasedassumptions', 'analytics'));
361 $result = new \stdClass();
362 $result->status = self::OK;
363 return $result;
364 }
365
369389c9
DM
366 // Increase memory limit.
367 $this->increase_memory();
368
369 $options['evaluation'] = true;
370 $this->init_analyser($options);
371
372 if (empty($this->get_indicators())) {
373 throw new \moodle_exception('errornoindicators', 'analytics');
374 }
375
376 // Before get_labelled_data call so we get an early exception if it is not ready.
377 $predictor = \core_analytics\manager::get_predictions_processor();
378
379 $datasets = $this->get_analyser()->get_labelled_data();
380
381 // No datasets generated.
382 if (empty($datasets)) {
383 $result = new \stdClass();
384 $result->status = self::NO_DATASET;
385 $result->info = $this->get_analyser()->get_logs();
386 return array($result);
387 }
388
389 if (!PHPUNIT_TEST && CLI_SCRIPT) {
390 echo PHP_EOL . get_string('processingsitecontents', 'analytics') . PHP_EOL;
391 }
392
393 $results = array();
394 foreach ($datasets as $timesplittingid => $dataset) {
395
396 $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingid);
397
398 $result = new \stdClass();
399
400 $dashestimesplittingid = str_replace('\\', '', $timesplittingid);
401 $outputdir = $this->get_output_dir(array('evaluation', $dashestimesplittingid));
402
403 // Evaluate the dataset, the deviation we accept in the results depends on the amount of iterations.
404 $predictorresult = $predictor->evaluate($this->model->id, self::ACCEPTED_DEVIATION,
405 self::EVALUATION_ITERATIONS, $dataset, $outputdir);
406
407 $result->status = $predictorresult->status;
408 $result->info = $predictorresult->info;
409
410 if (isset($predictorresult->score)) {
411 $result->score = $predictorresult->score;
412 } else {
413 // Prediction processors may return an error, default to 0 score in that case.
414 $result->score = 0;
415 }
416
417 $dir = false;
418 if (!empty($predictorresult->dir)) {
419 $dir = $predictorresult->dir;
420 }
421
422 $result->logid = $this->log_result($timesplitting->get_id(), $result->score, $dir, $result->info);
423
424 $results[$timesplitting->get_id()] = $result;
425 }
426
427 return $results;
428 }
429
430 /**
431 * train
432 *
433 * @return \stdClass
434 */
435 public function train() {
436 global $DB;
437
a40952d3
DM
438 if ($this->is_static()) {
439 $this->get_analyser()->add_log(get_string('notrainingbasedassumptions', 'analytics'));
440 $result = new \stdClass();
441 $result->status = self::OK;
442 return $result;
443 }
444
369389c9
DM
445 // Increase memory limit.
446 $this->increase_memory();
447
a40952d3 448 if (!$this->is_enabled() || empty($this->model->timesplitting)) {
369389c9
DM
449 throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
450 }
451
452 if (empty($this->get_indicators())) {
453 throw new \moodle_exception('errornoindicators', 'analytics');
454 }
455
456 // Before get_labelled_data call so we get an early exception if it is not writable.
457 $outputdir = $this->get_output_dir(array('execution'));
458
459 // Before get_labelled_data call so we get an early exception if it is not ready.
460 $predictor = \core_analytics\manager::get_predictions_processor();
461
462 $datasets = $this->get_analyser()->get_labelled_data();
463
464 // No training if no files have been provided.
465 if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
466
467 $result = new \stdClass();
468 $result->status = self::NO_DATASET;
469 $result->info = $this->get_analyser()->get_logs();
470 return $result;
471 }
472 $samplesfile = $datasets[$this->model->timesplitting];
473
474 // Train using the dataset.
475 $predictorresult = $predictor->train($this->get_unique_id(), $samplesfile, $outputdir);
476
477 $result = new \stdClass();
478 $result->status = $predictorresult->status;
479 $result->info = $predictorresult->info;
480
481 $this->flag_file_as_used($samplesfile, 'trained');
482
483 // Mark the model as trained if it wasn't.
484 if ($this->model->trained == false) {
485 $this->mark_as_trained();
486 }
487
488 return $result;
489 }
490
491 /**
492 * predict
493 *
494 * @return \stdClass
495 */
496 public function predict() {
497 global $DB;
498
499 // Increase memory limit.
500 $this->increase_memory();
501
a40952d3 502 if (!$this->is_enabled() || empty($this->model->timesplitting)) {
369389c9
DM
503 throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
504 }
505
506 if (empty($this->get_indicators())) {
507 throw new \moodle_exception('errornoindicators', 'analytics');
508 }
509
510 // Before get_unlabelled_data call so we get an early exception if it is not writable.
511 $outputdir = $this->get_output_dir(array('execution'));
512
513 // Before get_unlabelled_data call so we get an early exception if it is not ready.
a40952d3
DM
514 if (!$this->is_static()) {
515 $predictor = \core_analytics\manager::get_predictions_processor();
516 }
369389c9
DM
517
518 $samplesdata = $this->get_analyser()->get_unlabelled_data();
519
520 // Get the prediction samples file.
521 if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
522
523 $result = new \stdClass();
524 $result->status = self::NO_DATASET;
525 $result->info = $this->get_analyser()->get_logs();
526 return $result;
527 }
528 $samplesfile = $samplesdata[$this->model->timesplitting];
529
530 // We need to throw an exception if we are trying to predict stuff that was already predicted.
531 $params = array('modelid' => $this->model->id, 'fileid' => $samplesfile->get_id(), 'action' => 'predicted');
532 if ($predicted = $DB->get_record('analytics_used_files', $params)) {
533 throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id());
534 }
535
a40952d3 536 $indicatorcalculations = \core_analytics\dataset_manager::get_structured_data($samplesfile);
369389c9 537
a40952d3 538 // Prepare the results object.
369389c9 539 $result = new \stdClass();
369389c9 540
a40952d3
DM
541 if ($this->is_static()) {
542 // Prediction based on assumptions.
543 $result->status = \core_analytics\model::OK;
544 $result->info = [];
545 $result->predictions = $this->get_static_predictions($indicatorcalculations);
546
547 } else {
548 // Defer the prediction to the machine learning backend.
549 $predictorresult = $predictor->predict($this->get_unique_id(), $samplesfile, $outputdir);
550
551 $result->status = $predictorresult->status;
552 $result->info = $predictorresult->info;
553 $result->predictions = array();
554 if ($predictorresult->predictions) {
555 foreach ($predictorresult->predictions as $sampleinfo) {
556
557 // We parse each prediction
558 switch (count($sampleinfo)) {
559 case 1:
560 // For whatever reason the predictions processor could not process this sample, we
561 // skip it and do nothing with it.
562 debugging($this->model->id . ' model predictions processor could not process the sample with id ' .
563 $sampleinfo[0], DEBUG_DEVELOPER);
564 continue;
565 case 2:
566 // Prediction processors that do not return a prediction score will have the maximum prediction
567 // score.
568 list($uniquesampleid, $prediction) = $sampleinfo;
569 $predictionscore = 1;
570 break;
571 case 3:
572 list($uniquesampleid, $prediction, $predictionscore) = $sampleinfo;
573 break;
574 default:
575 break;
576 }
577 $predictiondata = (object)['prediction' => $prediction, 'predictionscore' => $predictionscore];
578 $result->predictions[$uniquesampleid] = $predictiondata;
579 }
580 }
581 }
369389c9
DM
582
583 // Here we will store all predictions' contexts, this will be used to limit which users will see those predictions.
584 $samplecontexts = array();
585
a40952d3
DM
586 if ($result->predictions) {
587 foreach ($result->predictions as $uniquesampleid => $prediction) {
369389c9 588
a40952d3 589 if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
369389c9
DM
590
591 // The unique sample id contains both the sampleid and the rangeindex.
592 list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
593
594 // Store the predicted values.
a40952d3
DM
595 $samplecontext = $this->save_prediction($sampleid, $rangeindex, $prediction->prediction, $prediction->predictionscore,
596 json_encode($indicatorcalculations[$uniquesampleid]));
369389c9
DM
597
598 // Also store all samples context to later generate insights or whatever action the target wants to perform.
599 $samplecontexts[$samplecontext->id] = $samplecontext;
600
601 $this->get_target()->prediction_callback($this->model->id, $sampleid, $rangeindex, $samplecontext,
a40952d3 602 $prediction->prediction, $prediction->predictionscore);
369389c9
DM
603 }
604 }
605 }
606
607 if (!empty($samplecontexts)) {
608 // Notify the target that all predictions have been processed.
609 $this->get_target()->generate_insights($this->model->id, $samplecontexts);
610
611 // Aggressive invalidation, the cost of filling up the cache is not high.
612 $cache = \cache::make('core', 'modelswithpredictions');
613 foreach ($samplecontexts as $context) {
614 $cache->delete($context->id);
615 }
616 }
617
618 $this->flag_file_as_used($samplesfile, 'predicted');
619
620 return $result;
621 }
622
a40952d3
DM
623 /**
624 * get_static_predictions
625 *
626 * @param array $indicatorcalculations
627 * @return \stdClass[]
628 */
629 protected function get_static_predictions(&$indicatorcalculations) {
630
631 // Group samples by analysable for \core_analytics\local\target::calculate.
632 $analysables = array();
633 // List all sampleids together.
634 $sampleids = array();
635
636 foreach ($indicatorcalculations as $uniquesampleid => $indicators) {
637 list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
638
639 $analysable = $this->get_analyser()->get_sample_analysable($sampleid);
640 $analysableclass = get_class($analysable);
641 if (empty($analysables[$analysableclass])) {
642 $analysables[$analysableclass] = array();
643 }
644 if (empty($analysables[$analysableclass][$rangeindex])) {
645 $analysables[$analysableclass][$rangeindex] = (object)[
646 'analysable' => $analysable,
647 'indicatorsdata' => array(),
648 'sampleids' => array()
649 ];
650 }
651 // Using the sampleid as a key so we can easily merge indicators data later.
652 $analysables[$analysableclass][$rangeindex]->indicatorsdata[$sampleid] = $indicators;
653 // We could use indicatorsdata keys but the amount of redundant data is not that big and leaves code below cleaner.
654 $analysables[$analysableclass][$rangeindex]->sampleids[$sampleid] = $sampleid;
655
656 // Accumulate sample ids to get all their associated data in 1 single db query (analyser::get_samples).
657 $sampleids[$sampleid] = $sampleid;
658 }
659
660 // Get all samples data.
661 list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
662
663 // Calculate the targets.
664 $calculations = array();
665 foreach ($analysables as $analysableclass => $rangedata) {
666 foreach ($rangedata as $rangeindex => $data) {
667
668 // Attach samples data and calculated indicators data.
669 $this->get_target()->clear_sample_data();
670 $this->get_target()->add_sample_data($samplesdata);
671 $this->get_target()->add_sample_data($data->indicatorsdata);
672
673 // Append new elements (we can not get duplicated because sample-analysable relation is N-1).
674 $range = $this->get_time_splitting()->get_range_by_index($rangeindex);
675 $calculations = $this->get_target()->calculate($data->sampleids, $data->analysable, $range['start'], $range['end']);
676
677 // Missing $indicatorcalculations values in $calculations are caused by is_valid_sample. We need to remove
678 // these $uniquesampleid from $indicatorcalculations because otherwise they will be stored as calculated
679 // by self::save_prediction.
680 $indicatorcalculations = array_filter($indicatorcalculations, function($indicators, $uniquesampleid) use ($calculations) {
681 list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
682 if (!isset($calculations[$sampleid])) {
683 debugging($uniquesampleid . ' discarded by is_valid_sample');
684 return false;
685 }
686 return true;
687 }, ARRAY_FILTER_USE_BOTH);
688
689 foreach ($calculations as $sampleid => $value) {
690
691 $uniquesampleid = $this->get_time_splitting()->append_rangeindex($sampleid, $rangeindex);
692
693 // Null means that the target couldn't calculate the sample, we also remove them from $indicatorcalculations.
694 if (is_null($calculations[$sampleid])) {
695 debugging($uniquesampleid . ' discarded by is_valid_sample');
696 unset($indicatorcalculations[$uniquesampleid]);
697 continue;
698 }
699
700 // Even if static predictions are based on assumptions we flag them as 100% because they are 100%
701 // true according to what the developer defined.
702 $predictions[$uniquesampleid] = (object)['prediction' => $value, 'predictionscore' => 1];
703 }
704 }
705 }
706 return $predictions;
707 }
708
369389c9
DM
709 /**
710 * save_prediction
711 *
712 * @param int $sampleid
713 * @param int $rangeindex
714 * @param int $prediction
715 * @param float $predictionscore
716 * @param string $calculations
717 * @return \context
718 */
719 protected function save_prediction($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) {
720 global $DB;
721
722 $context = $this->get_analyser()->sample_access_context($sampleid);
723
724 $record = new \stdClass();
725 $record->modelid = $this->model->id;
726 $record->contextid = $context->id;
727 $record->sampleid = $sampleid;
728 $record->rangeindex = $rangeindex;
729 $record->prediction = $prediction;
730 $record->predictionscore = $predictionscore;
731 $record->calculations = $calculations;
732 $record->timecreated = time();
733 $DB->insert_record('analytics_predictions', $record);
734
735 return $context;
736 }
737
738 /**
739 * enable
740 *
741 * @param string $timesplittingid
742 * @return void
743 */
744 public function enable($timesplittingid = false) {
745 global $DB;
746
747 $now = time();
748
749 if ($timesplittingid && $timesplittingid !== $this->model->timesplitting) {
750
751 if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) {
752 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
753 }
754
755 if (substr($timesplittingid, 0, 1) !== '\\') {
756 throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
757 }
758
759 $this->model->timesplitting = $timesplittingid;
760 $this->model->version = $now;
761 }
762 $this->model->enabled = 1;
763 $this->model->timemodified = $now;
764
765 // We don't always update timemodified intentionally as we reserve it for target, indicators or timesplitting updates.
766 $DB->update_record('analytics_models', $this->model);
767
768 // It needs to be reset (just in case, we may already used it).
769 $this->uniqueid = null;
770 }
771
a40952d3
DM
772 /**
773 * is_static
774 *
775 * @return bool
776 */
777 public function is_static() {
778 return (bool)$this->get_target()->based_on_assumptions();
779 }
780
369389c9
DM
781 /**
782 * is_enabled
783 *
784 * @return bool
785 */
786 public function is_enabled() {
787 return (bool)$this->model->enabled;
788 }
789
790 /**
791 * is_trained
792 *
793 * @return bool
794 */
795 public function is_trained() {
a40952d3
DM
796 // Models which targets are based on assumptions do not need training.
797 return (bool)$this->model->trained || $this->is_static();
369389c9
DM
798 }
799
800 /**
801 * mark_as_trained
802 *
803 * @return void
804 */
805 public function mark_as_trained() {
806 global $DB;
807
808 $this->model->trained = 1;
809 $DB->update_record('analytics_models', $this->model);
810 }
811
812 /**
813 * get_predictions_contexts
814 *
815 * @return \stdClass[]
816 */
817 public function get_predictions_contexts() {
818 global $DB;
819
820 $sql = "SELECT DISTINCT contextid FROM {analytics_predictions} WHERE modelid = ?";
821 return $DB->get_records_sql($sql, array($this->model->id));
822 }
823
824 /**
825 * Whether predictions exist for this context.
826 *
827 * @param \context $context
828 * @return bool
829 */
830 public function predictions_exist(\context $context) {
831 global $DB;
832
833 // Filters out previous predictions keeping only the last time range one.
834 $select = "modelid = :modelid AND contextid = :contextid";
6ec2ae0f 835 $params = array('modelid' => $this->model->id, 'contextid' => $context->id);
369389c9
DM
836 return $DB->record_exists_select('analytics_predictions', $select, $params);
837 }
838
839 /**
840 * Gets the predictions for this context.
841 *
842 * @param \context $context
843 * @return \core_analytics\prediction[]
844 */
845 public function get_predictions(\context $context) {
846 global $DB;
847
848 // Filters out previous predictions keeping only the last time range one.
849 $sql = "SELECT tip.*
850 FROM {analytics_predictions} tip
851 JOIN (
852 SELECT sampleid, max(rangeindex) AS rangeindex
853 FROM {analytics_predictions}
854 WHERE modelid = ? and contextid = ?
855 GROUP BY sampleid
856 ) tipsub
857 ON tip.sampleid = tipsub.sampleid AND tip.rangeindex = tipsub.rangeindex
858 WHERE tip.modelid = ? and tip.contextid = ?";
859 $params = array($this->model->id, $context->id, $this->model->id, $context->id);
860 if (!$predictions = $DB->get_records_sql($sql, $params)) {
861 return array();
862 }
863
864 // Get predicted samples' ids.
865 $sampleids = array_map(function($prediction) {
866 return $prediction->sampleid;
867 }, $predictions);
868
869 list($unused, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
870
871 // Add samples data as part of each prediction.
872 foreach ($predictions as $predictionid => $predictiondata) {
873
874 $sampleid = $predictiondata->sampleid;
875
876 // Filter out predictions which samples are not available anymore.
877 if (empty($samplesdata[$sampleid])) {
878 unset($predictions[$predictionid]);
879 continue;
880 }
881
882 // Replace stdClass object by \core_analytics\prediction objects.
883 $prediction = new \core_analytics\prediction($predictiondata, $samplesdata[$sampleid]);
884
885 $predictions[$predictionid] = $prediction;
886 }
887
888 return $predictions;
889 }
890
891 /**
892 * prediction_sample_data
893 *
894 * @param \stdClass $predictionobj
895 * @return array
896 */
897 public function prediction_sample_data($predictionobj) {
898
899 list($unused, $samplesdata) = $this->get_analyser()->get_samples(array($predictionobj->sampleid));
900
901 if (empty($samplesdata[$predictionobj->sampleid])) {
902 throw new \moodle_exception('errorsamplenotavailable', 'analytics');
903 }
904
905 return $samplesdata[$predictionobj->sampleid];
906 }
907
908 /**
909 * prediction_sample_description
910 *
911 * @param \core_analytics\prediction $prediction
912 * @return array 2 elements: list(string, \renderable)
913 */
914 public function prediction_sample_description(\core_analytics\prediction $prediction) {
915 return $this->get_analyser()->sample_description($prediction->get_prediction_data()->sampleid,
916 $prediction->get_prediction_data()->contextid, $prediction->get_sample_data());
917 }
918
919 /**
920 * Returns the output directory for prediction processors.
921 *
922 * Directory structure as follows:
923 * - Evaluation runs:
924 * models/$model->id/$model->version/evaluation/$model->timesplitting
925 * - Training & prediction runs:
926 * models/$model->id/$model->version/execution
927 *
928 * @param array $subdirs
929 * @return string
930 */
931 protected function get_output_dir($subdirs = array()) {
932 global $CFG;
933
934 $subdirstr = '';
935 foreach ($subdirs as $subdir) {
936 $subdirstr .= DIRECTORY_SEPARATOR . $subdir;
937 }
938
939 $outputdir = get_config('analytics', 'modeloutputdir');
940 if (empty($outputdir)) {
941 // Apply default value.
942 $outputdir = rtrim($CFG->dataroot, '/') . DIRECTORY_SEPARATOR . 'models';
943 }
944
945 // Append model id and version + subdirs.
946 $outputdir .= DIRECTORY_SEPARATOR . $this->model->id . DIRECTORY_SEPARATOR . $this->model->version . $subdirstr;
947
948 make_writable_directory($outputdir);
949
950 return $outputdir;
951 }
952
953 /**
954 * get_unique_id
955 *
956 * @return string
957 */
958 public function get_unique_id() {
959 global $CFG;
960
961 if (!is_null($this->uniqueid)) {
962 return $this->uniqueid;
963 }
964
965 // Generate a unique id for this site, this model and this time splitting method, considering the last time
966 // that the model target and indicators were updated.
967 $ids = array($CFG->wwwroot, $CFG->dirroot, $CFG->prefix, $this->model->id, $this->model->version);
968 $this->uniqueid = sha1(implode('$$', $ids));
969
970 return $this->uniqueid;
971 }
972
973 /**
974 * Exports the model data.
975 *
976 * @return \stdClass
977 */
978 public function export() {
979 $data = clone $this->model;
980 $data->target = $this->get_target()->get_name();
981
982 if ($timesplitting = $this->get_time_splitting()) {
983 $data->timesplitting = $timesplitting->get_name();
984 }
985
986 $data->indicators = array();
987 foreach ($this->get_indicators() as $indicator) {
988 $data->indicators[] = $indicator->get_name();
989 }
990 return $data;
991 }
992
993 /**
994 * flag_file_as_used
995 *
996 * @param \stored_file $file
997 * @param string $action
998 * @return void
999 */
1000 protected function flag_file_as_used(\stored_file $file, $action) {
1001 global $DB;
1002
1003 $usedfile = new \stdClass();
1004 $usedfile->modelid = $this->model->id;
1005 $usedfile->fileid = $file->get_id();
1006 $usedfile->action = $action;
1007 $usedfile->time = time();
1008 $DB->insert_record('analytics_used_files', $usedfile);
1009 }
1010
1011 /**
1012 * log_result
1013 *
1014 * @param string $timesplittingid
1015 * @param float $score
1016 * @param string $dir
1017 * @param array $info
1018 * @return int The inserted log id
1019 */
1020 protected function log_result($timesplittingid, $score, $dir = false, $info = false) {
1021 global $DB, $USER;
1022
1023 $log = new \stdClass();
1024 $log->modelid = $this->get_id();
1025 $log->version = $this->model->version;
1026 $log->target = $this->model->target;
1027 $log->indicators = $this->model->indicators;
1028 $log->timesplitting = $timesplittingid;
1029 $log->dir = $dir;
1030 if ($info) {
1031 // Ensure it is not an associative array.
1032 $log->info = json_encode(array_values($info));
1033 }
1034 $log->score = $score;
1035 $log->timecreated = time();
1036 $log->usermodified = $USER->id;
1037
1038 return $DB->insert_record('analytics_models_log', $log);
1039 }
1040
1041 /**
1042 * Utility method to return indicator class names from a list of indicator objects
1043 *
1044 * @param \core_analytics\local\indicator\base[] $indicators
1045 * @return string[]
1046 */
1047 private static function indicator_classes($indicators) {
1048
1049 // What we want to check and store are the indicator classes not the keys.
1050 $indicatorclasses = array();
1051 foreach ($indicators as $indicator) {
1052 if (!\core_analytics\manager::is_valid($indicator, '\core_analytics\local\indicator\base')) {
1053 if (!is_object($indicator) && !is_scalar($indicator)) {
1054 $indicator = strval($indicator);
1055 } else if (is_object($indicator)) {
1056 $indicator = get_class($indicator);
1057 }
1058 throw new \moodle_exception('errorinvalidindicator', 'analytics', '', $indicator);
1059 }
b0c24929 1060 $indicatorclasses[] = $indicator->get_id();
369389c9
DM
1061 }
1062
1063 return $indicatorclasses;
1064 }
1065
1066 /**
1067 * Clears the model training and prediction data.
1068 *
1069 * Executed after updating model critical elements like the time splitting method
1070 * or the indicators.
1071 *
1072 * @return void
1073 */
1074 private function clear_model() {
1075 global $DB;
1076
1077 $DB->delete_records('analytics_predict_ranges', array('modelid' => $this->model->id));
1078 $DB->delete_records('analytics_predictions', array('modelid' => $this->model->id));
1079 $DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
1080 $DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
1081
1082 $cache = \cache::make('core', 'modelswithpredictions');
1083 $result = $cache->purge();
1084 }
1085
1086 private function increase_memory() {
1087 if (ini_get('memory_limit') != -1) {
1088 raise_memory_limit(MEMORY_HUGE);
1089 }
1090 }
1091
1092}