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