Merge branch 'MDL-59685-master' of git://github.com/damyon/moodle
[moodle.git] / analytics / classes / local / time_splitting / base.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Base time splitting method.
19  *
20  * @package   core_analytics
21  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace core_analytics\local\time_splitting;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Base time splitting method.
31  *
32  * @package   core_analytics
33  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
34  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 abstract class base {
38     /**
39      * @var string
40      */
41     protected $id;
43     /**
44      * @var \core_analytics\analysable
45      */
46     protected $analysable;
49     /**
50      * @var int[]
51      */
52     protected $sampleids;
54     /**
55      * @var string
56      */
57     protected $samplesorigin;
59     /**
60      * @var array
61      */
62     protected $ranges = [];
64     /**
65      * @var \core_analytics\local\indicator\base
66      */
67     protected static $indicators = [];
69     /**
70      * @var bool
71      */
72     protected $evaluation = false;
74     /**
75      * Define the time splitting methods ranges.
76      *
77      * 'time' value defines when predictions are executed, their values will be compared with
78      * the current time in ready_to_predict
79      *
80      * @return array('start' => time(), 'end' => time(), 'time' => time())
81      */
82     abstract protected function define_ranges();
84     /**
85      * Returns a lang_string object representing the name for the time splitting method.
86      *
87      * Used as column identificator.
88      *
89      * If there is a corresponding '_help' string this will be shown as well.
90      *
91      * @return \lang_string
92      */
93     public static abstract function get_name() : \lang_string;
95     /**
96      * Returns the time splitting method id.
97      *
98      * @return string
99      */
100     public function get_id() {
101         return '\\' . get_class($this);
102     }
104     /**
105      * Returns current evaluation value.
106      *
107      * @return bool
108      */
109     public function is_evaluating() {
110         return $this->evaluation;
111     }
113     /**
114      * Sets the evaluation flag.
115      *
116      * @param bool $evaluation
117      */
118     public function set_evaluating($evaluation) {
119         $this->evaluation = (bool)$evaluation;
120     }
122     /**
123      * Assigns the analysable and updates the time ranges according to the analysable start and end dates.
124      *
125      * @param \core_analytics\analysable $analysable
126      * @return void
127      */
128     public function set_analysable(\core_analytics\analysable $analysable) {
129         $this->analysable = $analysable;
130         $this->ranges = $this->define_ranges();
131         $this->validate_ranges();
132     }
134     /**
135      * get_analysable
136      *
137      * @return \core_analytics\analysable
138      */
139     public function get_analysable() {
140         return $this->analysable;
141     }
143     /**
144      * Returns whether the course can be processed by this time splitting method or not.
145      *
146      * @param \core_analytics\analysable $analysable
147      * @return bool
148      */
149     public function is_valid_analysable(\core_analytics\analysable $analysable) {
150         return true;
151     }
153     /**
154      * Should we predict this time range now?
155      *
156      * @param array $range
157      * @return bool
158      */
159     public function ready_to_predict($range) {
160         if ($range['time'] <= time()) {
161             return true;
162         }
163         return false;
164     }
166     /**
167      * Calculates indicators and targets.
168      *
169      * @param array $sampleids
170      * @param string $samplesorigin
171      * @param \core_analytics\local\indicator\base[] $indicators
172      * @param array $ranges
173      * @param \core_analytics\local\target\base $target
174      * @return array|bool
175      */
176     public function calculate(&$sampleids, $samplesorigin, $indicators, $ranges, $target = false) {
178         $calculatedtarget = false;
179         if ($target) {
180             // We first calculate the target because analysable data may still be invalid or none
181             // of the analysable samples may be valid ($sampleids is also passed by reference).
182             $calculatedtarget = $target->calculate($sampleids, $this->analysable);
184             // We remove samples we can not calculate their target.
185             $sampleids = array_filter($sampleids, function($sampleid) use ($calculatedtarget) {
186                 if (is_null($calculatedtarget[$sampleid])) {
187                     return false;
188                 }
189                 return true;
190             });
191         }
193         // No need to continue calculating if the target couldn't be calculated for any sample.
194         if (empty($sampleids)) {
195             return false;
196         }
198         $dataset = $this->calculate_indicators($sampleids, $samplesorigin, $indicators, $ranges);
200         // Now that we have the indicators in place we can add the time range indicators (and target if provided) to each of them.
201         $this->fill_dataset($dataset, $calculatedtarget);
203         $this->add_metadata($dataset, $indicators, $target);
205         if (!PHPUNIT_TEST && CLI_SCRIPT) {
206             echo PHP_EOL;
207         }
209         return $dataset;
210     }
212     /**
213      * Calculates indicators.
214      *
215      * @param array $sampleids
216      * @param string $samplesorigin
217      * @param \core_analytics\local\indicator\base[] $indicators
218      * @param array $ranges
219      * @return array
220      */
221     protected function calculate_indicators($sampleids, $samplesorigin, $indicators, $ranges) {
222         global $DB;
224         $dataset = array();
226         // Faster to run 1 db query per range.
227         $existingcalculations = array();
228         foreach ($ranges as $rangeindex => $range) {
229             // Load existing calculations.
230             $existingcalculations[$rangeindex] = \core_analytics\manager::get_indicator_calculations($this->analysable,
231                 $range['start'], $range['end'], $samplesorigin);
232         }
234         // Fill the dataset samples with indicators data.
235         $newcalculations = array();
236         foreach ($indicators as $indicator) {
238             // Per-range calculations.
239             foreach ($ranges as $rangeindex => $range) {
241                 // Indicator instances are per-range.
242                 $rangeindicator = clone $indicator;
244                 $prevcalculations = array();
245                 if (!empty($existingcalculations[$rangeindex][$rangeindicator->get_id()])) {
246                     $prevcalculations = $existingcalculations[$rangeindex][$rangeindicator->get_id()];
247                 }
249                 // Calculate the indicator for each sample in this time range.
250                 list($samplesfeatures, $newindicatorcalculations) = $rangeindicator->calculate($sampleids,
251                     $samplesorigin, $range['start'], $range['end'], $prevcalculations);
253                 // Copy the features data to the dataset.
254                 foreach ($samplesfeatures as $analysersampleid => $features) {
256                     $uniquesampleid = $this->append_rangeindex($analysersampleid, $rangeindex);
258                     // Init the sample if it is still empty.
259                     if (!isset($dataset[$uniquesampleid])) {
260                         $dataset[$uniquesampleid] = array();
261                     }
263                     // Append the features indicator features at the end of the sample.
264                     $dataset[$uniquesampleid] = array_merge($dataset[$uniquesampleid], $features);
265                 }
267                 if (!$this->is_evaluating()) {
268                     $timecreated = time();
269                     foreach ($newindicatorcalculations as $sampleid => $calculatedvalue) {
270                         // Prepare the new calculations to be stored into DB.
272                         $indcalc = new \stdClass();
273                         $indcalc->contextid = $this->analysable->get_context()->id;
274                         $indcalc->starttime = $range['start'];
275                         $indcalc->endtime = $range['end'];
276                         $indcalc->sampleid = $sampleid;
277                         $indcalc->sampleorigin = $samplesorigin;
278                         $indcalc->indicator = $rangeindicator->get_id();
279                         $indcalc->value = $calculatedvalue;
280                         $indcalc->timecreated = $timecreated;
281                         $newcalculations[] = $indcalc;
282                     }
283                 }
284             }
286             if (!$this->is_evaluating()) {
287                 $batchsize = self::get_insert_batch_size();
288                 if (count($newcalculations) > $batchsize) {
289                     // We don't want newcalculations array to grow too much as we already keep the
290                     // system memory busy storing $dataset contents.
292                     // Insert from the beginning.
293                     $remaining = array_splice($newcalculations, $batchsize);
295                     // Sorry mssql and oracle, this will be slow.
296                     $DB->insert_records('analytics_indicator_calc', $newcalculations);
297                     $newcalculations = $remaining;
298                 }
299             }
300         }
302         if (!$this->is_evaluating() && $newcalculations) {
303             // Insert the remaining records.
304             $DB->insert_records('analytics_indicator_calc', $newcalculations);
305         }
307         return $dataset;
308     }
310     /**
311      * Adds time range indicators and the target to each sample.
312      *
313      * This will identify the sample as belonging to a specific range.
314      *
315      * @param array $dataset
316      * @param array $calculatedtarget
317      * @return void
318      */
319     protected function fill_dataset(&$dataset, $calculatedtarget = false) {
321         $nranges = count($this->get_all_ranges());
323         foreach ($dataset as $uniquesampleid => $unmodified) {
325             list($analysersampleid, $rangeindex) = $this->infer_sample_info($uniquesampleid);
327             // No need to add range features if this time splitting method only defines one time range.
328             if ($nranges > 1) {
330                 // 1 column for each range.
331                 $timeindicators = array_fill(0, $nranges, 0);
333                 $timeindicators[$rangeindex] = 1;
335                 $dataset[$uniquesampleid] = array_merge($timeindicators, $dataset[$uniquesampleid]);
336             }
338             if ($calculatedtarget) {
339                 // Add this sampleid's calculated target and the end.
340                 $dataset[$uniquesampleid][] = $calculatedtarget[$analysersampleid];
342             } else {
343                 // Add this sampleid, it will be used to identify the prediction that comes back from
344                 // the predictions processor.
345                 array_unshift($dataset[$uniquesampleid], $uniquesampleid);
346             }
347         }
348     }
350     /**
351      * Adds dataset context info.
352      *
353      * The final dataset document will look like this:
354      * ----------------------------------------------------
355      * metadata1,metadata2,metadata3,.....
356      * value1, value2, value3,.....
357      *
358      * indicator1,indicator2,indicator3,indicator4,.....
359      * stud1value1,stud1value2,stud1value3,stud1value4,.....
360      * stud2value1,stud2value2,stud2value3,stud2value4,.....
361      * .....
362      * ----------------------------------------------------
363      *
364      * @param array $dataset
365      * @param \core_analytics\local\indicator\base[] $indicators
366      * @param \core_analytics\local\target\base|false $target
367      * @return void
368      */
369     protected function add_metadata(&$dataset, $indicators, $target = false) {
371         $metadata = array(
372             'timesplitting' => $this->get_id(),
373             // If no target the first column is the sampleid, if target the last column is the target.
374             // This will need to be updated when we support unsupervised learning models.
375             'nfeatures' => count(current($dataset)) - 1
376         );
378         // The first 2 samples will be used to store metadata about the dataset.
379         $metadatacolumns = [];
380         $metadatavalues = [];
381         foreach ($metadata as $key => $value) {
382             $metadatacolumns[] = $key;
383             $metadatavalues[] = $value;
384         }
386         $headers = $this->get_headers($indicators, $target);
388         // This will also reset samples' dataset keys.
389         array_unshift($dataset, $metadatacolumns, $metadatavalues, $headers);
390     }
392     /**
393      * Returns the ranges used by this time splitting method.
394      *
395      * @return array
396      */
397     public function get_all_ranges() {
398         return $this->ranges;
399     }
401     /**
402      * Returns range data by its index.
403      *
404      * @param int $rangeindex
405      * @return array|false Range data or false if the index is not part of the existing ranges.
406      */
407     public function get_range_by_index($rangeindex) {
408         if (!isset($this->ranges[$rangeindex])) {
409             return false;
410         }
411         return $this->ranges[$rangeindex];
412     }
414     /**
415      * Generates a unique sample id (sample in a range index).
416      *
417      * @param int $sampleid
418      * @param int $rangeindex
419      * @return string
420      */
421     public function append_rangeindex($sampleid, $rangeindex) {
422         return $sampleid . '-' . $rangeindex;
423     }
425     /**
426      * Returns the sample id and the range index from a uniquesampleid.
427      *
428      * @param string $uniquesampleid
429      * @return array array($sampleid, $rangeindex)
430      */
431     public function infer_sample_info($uniquesampleid) {
432         return explode('-', $uniquesampleid);
433     }
435     /**
436      * Returns the headers for the csv file based on the indicators and the target.
437      *
438      * @param \core_analytics\local\indicator\base[] $indicators
439      * @param \core_analytics\local\target\base|false $target
440      * @return string[]
441      */
442     protected function get_headers($indicators, $target = false) {
443         // 3rd column will contain the indicator ids.
444         $headers = array();
446         if (!$target) {
447             // The first column is the sampleid.
448             $headers[] = 'sampleid';
449         }
451         // We always have 1 column for each time splitting method range, it does not depend on how
452         // many ranges we calculated.
453         $ranges = $this->get_all_ranges();
454         if (count($ranges) > 1) {
455             foreach ($ranges as $rangeindex => $range) {
456                 $headers[] = 'range/' . $rangeindex;
457             }
458         }
460         // Model indicators.
461         foreach ($indicators as $indicator) {
462             $headers = array_merge($headers, $indicator::get_feature_headers());
463         }
465         // The target as well.
466         if ($target) {
467             $headers[] = $target->get_id();
468         }
470         return $headers;
471     }
473     /**
474      * Validates the time splitting method ranges.
475      *
476      * @throws \coding_exception
477      * @return void
478      */
479     protected function validate_ranges() {
480         foreach ($this->ranges as $key => $range) {
481             if (!isset($this->ranges[$key]['start']) || !isset($this->ranges[$key]['end']) ||
482                     !isset($this->ranges[$key]['time'])) {
483                 throw new \coding_exception($this->get_id() . ' time splitting method "' . $key .
484                     '" range is not fully defined. We need a start timestamp and an end timestamp.');
485             }
486         }
487     }
489     /**
490      * Returns the batch size used for insert_records.
491      *
492      * This method tries to find the best batch size without getting
493      * into dml internals. Maximum 1000 records to save memory.
494      *
495      * @return int
496      */
497     private static function get_insert_batch_size() {
498         global $DB;
500         // 500 is pgsql default so using 1000 is fine, no other db driver uses a hardcoded value.
501         if (empty($DB->dboptions['bulkinsertsize'])) {
502             return 1000;
503         }
505         $bulkinsert = $DB->dboptions['bulkinsertsize'];
506         if ($bulkinsert < 1000) {
507             return $bulkinsert;
508         }
510         while ($bulkinsert > 1000) {
511             $bulkinsert = round($bulkinsert / 2, 0);
512         }
514         return (int)$bulkinsert;
515     }