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