f45934a8eed45254e286a3914662ce348f959c26
[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             // Hook to allow indicators to store analysable-dependant data.
239             $indicator->fill_per_analysable_caches($this->analysable);
241             // Per-range calculations.
242             foreach ($ranges as $rangeindex => $range) {
244                 // Indicator instances are per-range.
245                 $rangeindicator = clone $indicator;
247                 $prevcalculations = array();
248                 if (!empty($existingcalculations[$rangeindex][$rangeindicator->get_id()])) {
249                     $prevcalculations = $existingcalculations[$rangeindex][$rangeindicator->get_id()];
250                 }
252                 // Calculate the indicator for each sample in this time range.
253                 list($samplesfeatures, $newindicatorcalculations) = $rangeindicator->calculate($sampleids,
254                     $samplesorigin, $range['start'], $range['end'], $prevcalculations);
256                 // Copy the features data to the dataset.
257                 foreach ($samplesfeatures as $analysersampleid => $features) {
259                     $uniquesampleid = $this->append_rangeindex($analysersampleid, $rangeindex);
261                     // Init the sample if it is still empty.
262                     if (!isset($dataset[$uniquesampleid])) {
263                         $dataset[$uniquesampleid] = array();
264                     }
266                     // Append the features indicator features at the end of the sample.
267                     $dataset[$uniquesampleid] = array_merge($dataset[$uniquesampleid], $features);
268                 }
270                 if (!$this->is_evaluating()) {
271                     $timecreated = time();
272                     foreach ($newindicatorcalculations as $sampleid => $calculatedvalue) {
273                         // Prepare the new calculations to be stored into DB.
275                         $indcalc = new \stdClass();
276                         $indcalc->contextid = $this->analysable->get_context()->id;
277                         $indcalc->starttime = $range['start'];
278                         $indcalc->endtime = $range['end'];
279                         $indcalc->sampleid = $sampleid;
280                         $indcalc->sampleorigin = $samplesorigin;
281                         $indcalc->indicator = $rangeindicator->get_id();
282                         $indcalc->value = $calculatedvalue;
283                         $indcalc->timecreated = $timecreated;
284                         $newcalculations[] = $indcalc;
285                     }
286                 }
287             }
289             if (!$this->is_evaluating()) {
290                 $batchsize = self::get_insert_batch_size();
291                 if (count($newcalculations) > $batchsize) {
292                     // We don't want newcalculations array to grow too much as we already keep the
293                     // system memory busy storing $dataset contents.
295                     // Insert from the beginning.
296                     $remaining = array_splice($newcalculations, $batchsize);
298                     // Sorry mssql and oracle, this will be slow.
299                     $DB->insert_records('analytics_indicator_calc', $newcalculations);
300                     $newcalculations = $remaining;
301                 }
302             }
303         }
305         if (!$this->is_evaluating() && $newcalculations) {
306             // Insert the remaining records.
307             $DB->insert_records('analytics_indicator_calc', $newcalculations);
308         }
310         return $dataset;
311     }
313     /**
314      * Adds time range indicators and the target to each sample.
315      *
316      * This will identify the sample as belonging to a specific range.
317      *
318      * @param array $dataset
319      * @param array $calculatedtarget
320      * @return void
321      */
322     protected function fill_dataset(&$dataset, $calculatedtarget = false) {
324         $nranges = count($this->get_all_ranges());
326         foreach ($dataset as $uniquesampleid => $unmodified) {
328             list($analysersampleid, $rangeindex) = $this->infer_sample_info($uniquesampleid);
330             // No need to add range features if this time splitting method only defines one time range.
331             if ($nranges > 1) {
333                 // 1 column for each range.
334                 $timeindicators = array_fill(0, $nranges, 0);
336                 $timeindicators[$rangeindex] = 1;
338                 $dataset[$uniquesampleid] = array_merge($timeindicators, $dataset[$uniquesampleid]);
339             }
341             if ($calculatedtarget) {
342                 // Add this sampleid's calculated target and the end.
343                 $dataset[$uniquesampleid][] = $calculatedtarget[$analysersampleid];
345             } else {
346                 // Add this sampleid, it will be used to identify the prediction that comes back from
347                 // the predictions processor.
348                 array_unshift($dataset[$uniquesampleid], $uniquesampleid);
349             }
350         }
351     }
353     /**
354      * Adds dataset context info.
355      *
356      * The final dataset document will look like this:
357      * ----------------------------------------------------
358      * metadata1,metadata2,metadata3,.....
359      * value1, value2, value3,.....
360      *
361      * indicator1,indicator2,indicator3,indicator4,.....
362      * stud1value1,stud1value2,stud1value3,stud1value4,.....
363      * stud2value1,stud2value2,stud2value3,stud2value4,.....
364      * .....
365      * ----------------------------------------------------
366      *
367      * @param array $dataset
368      * @param \core_analytics\local\indicator\base[] $indicators
369      * @param \core_analytics\local\target\base|false $target
370      * @return void
371      */
372     protected function add_metadata(&$dataset, $indicators, $target = false) {
374         $metadata = array(
375             'timesplitting' => $this->get_id(),
376             // If no target the first column is the sampleid, if target the last column is the target.
377             // This will need to be updated when we support unsupervised learning models.
378             'nfeatures' => count(current($dataset)) - 1
379         );
381         // The first 2 samples will be used to store metadata about the dataset.
382         $metadatacolumns = [];
383         $metadatavalues = [];
384         foreach ($metadata as $key => $value) {
385             $metadatacolumns[] = $key;
386             $metadatavalues[] = $value;
387         }
389         $headers = $this->get_headers($indicators, $target);
391         // This will also reset samples' dataset keys.
392         array_unshift($dataset, $metadatacolumns, $metadatavalues, $headers);
393     }
395     /**
396      * Returns the ranges used by this time splitting method.
397      *
398      * @return array
399      */
400     public function get_all_ranges() {
401         return $this->ranges;
402     }
404     /**
405      * Returns range data by its index.
406      *
407      * @param int $rangeindex
408      * @return array|false Range data or false if the index is not part of the existing ranges.
409      */
410     public function get_range_by_index($rangeindex) {
411         if (!isset($this->ranges[$rangeindex])) {
412             return false;
413         }
414         return $this->ranges[$rangeindex];
415     }
417     /**
418      * Generates a unique sample id (sample in a range index).
419      *
420      * @param int $sampleid
421      * @param int $rangeindex
422      * @return string
423      */
424     public function append_rangeindex($sampleid, $rangeindex) {
425         return $sampleid . '-' . $rangeindex;
426     }
428     /**
429      * Returns the sample id and the range index from a uniquesampleid.
430      *
431      * @param string $uniquesampleid
432      * @return array array($sampleid, $rangeindex)
433      */
434     public function infer_sample_info($uniquesampleid) {
435         return explode('-', $uniquesampleid);
436     }
438     /**
439      * Returns the headers for the csv file based on the indicators and the target.
440      *
441      * @param \core_analytics\local\indicator\base[] $indicators
442      * @param \core_analytics\local\target\base|false $target
443      * @return string[]
444      */
445     protected function get_headers($indicators, $target = false) {
446         // 3rd column will contain the indicator ids.
447         $headers = array();
449         if (!$target) {
450             // The first column is the sampleid.
451             $headers[] = 'sampleid';
452         }
454         // We always have 1 column for each time splitting method range, it does not depend on how
455         // many ranges we calculated.
456         $ranges = $this->get_all_ranges();
457         if (count($ranges) > 1) {
458             foreach ($ranges as $rangeindex => $range) {
459                 $headers[] = 'range/' . $rangeindex;
460             }
461         }
463         // Model indicators.
464         foreach ($indicators as $indicator) {
465             $headers = array_merge($headers, $indicator::get_feature_headers());
466         }
468         // The target as well.
469         if ($target) {
470             $headers[] = $target->get_id();
471         }
473         return $headers;
474     }
476     /**
477      * Validates the time splitting method ranges.
478      *
479      * @throws \coding_exception
480      * @return void
481      */
482     protected function validate_ranges() {
483         foreach ($this->ranges as $key => $range) {
484             if (!isset($this->ranges[$key]['start']) || !isset($this->ranges[$key]['end']) ||
485                     !isset($this->ranges[$key]['time'])) {
486                 throw new \coding_exception($this->get_id() . ' time splitting method "' . $key .
487                     '" range is not fully defined. We need a start timestamp and an end timestamp.');
488             }
489         }
490     }
492     /**
493      * Returns the batch size used for insert_records.
494      *
495      * This method tries to find the best batch size without getting
496      * into dml internals. Maximum 1000 records to save memory.
497      *
498      * @return int
499      */
500     private static function get_insert_batch_size() {
501         global $DB;
503         // 500 is pgsql default so using 1000 is fine, no other db driver uses a hardcoded value.
504         if (empty($DB->dboptions['bulkinsertsize'])) {
505             return 1000;
506         }
508         $bulkinsert = $DB->dboptions['bulkinsertsize'];
509         if ($bulkinsert < 1000) {
510             return $bulkinsert;
511         }
513         while ($bulkinsert > 1000) {
514             $bulkinsert = round($bulkinsert / 2, 0);
515         }
517         return (int)$bulkinsert;
518     }