MDL-59779 analytics: Alternative to query log table
[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      * Define the time splitting methods ranges.
71      *
72      * 'time' value defines when predictions are executed, their values will be compared with
73      * the current time in ready_to_predict
74      *
75      * @return array('start' => time(), 'end' => time(), 'time' => time())
76      */
77     abstract protected function define_ranges();
79     /**
80      * The time splitting method name.
81      *
82      * It is very recommendable to overwrite this method as this name appears in the UI.
83      * @return string
84      */
85     public function get_name() {
86         return $this->get_id();
87     }
89     /**
90      * Returns the time splitting method id.
91      *
92      * @return string
93      */
94     public function get_id() {
95         return '\\' . get_class($this);
96     }
98     /**
99      * Assigns the analysable and updates the time ranges according to the analysable start and end dates.
100      *
101      * @param \core_analytics\analysable $analysable
102      * @return void
103      */
104     public function set_analysable(\core_analytics\analysable $analysable) {
105         $this->analysable = $analysable;
106         $this->ranges = $this->define_ranges();
107         $this->validate_ranges();
108     }
110     /**
111      * get_analysable
112      *
113      * @return \core_analytics\analysable
114      */
115     public function get_analysable() {
116         return $this->analysable;
117     }
119     /**
120      * Returns whether the course can be processed by this time splitting method or not.
121      *
122      * @param \core_analytics\analysable $analysable
123      * @return bool
124      */
125     public function is_valid_analysable(\core_analytics\analysable $analysable) {
126         return true;
127     }
129     /**
130      * Should we predict this time range now?
131      *
132      * @param array $range
133      * @return bool
134      */
135     public function ready_to_predict($range) {
136         if ($range['time'] <= time()) {
137             return true;
138         }
139         return false;
140     }
142     /**
143      * Calculates indicators and targets.
144      *
145      * @param array $sampleids
146      * @param string $samplesorigin
147      * @param \core_analytics\local\indicator\base[] $indicators
148      * @param array $ranges
149      * @param \core_analytics\local\target\base $target
150      * @return array|bool
151      */
152     public function calculate(&$sampleids, $samplesorigin, $indicators, $ranges, $target = false) {
154         $calculatedtarget = false;
155         if ($target) {
156             // We first calculate the target because analysable data may still be invalid or none
157             // of the analysable samples may be valid ($sampleids is also passed by reference).
158             $calculatedtarget = $target->calculate($sampleids, $this->analysable);
160             // We remove samples we can not calculate their target.
161             $sampleids = array_filter($sampleids, function($sampleid) use ($calculatedtarget) {
162                 if (is_null($calculatedtarget[$sampleid])) {
163                     return false;
164                 }
165                 return true;
166             });
167         }
169         // No need to continue calculating if the target couldn't be calculated for any sample.
170         if (empty($sampleids)) {
171             return false;
172         }
174         $dataset = $this->calculate_indicators($sampleids, $samplesorigin, $indicators, $ranges);
176         // Now that we have the indicators in place we can add the time range indicators (and target if provided) to each of them.
177         $this->fill_dataset($dataset, $calculatedtarget);
179         $this->add_metadata($dataset, $indicators, $target);
181         if (!PHPUNIT_TEST && CLI_SCRIPT) {
182             echo PHP_EOL;
183         }
185         return $dataset;
186     }
188     /**
189      * Calculates indicators.
190      *
191      * @param array $sampleids
192      * @param string $samplesorigin
193      * @param \core_analytics\local\indicator\base[] $indicators
194      * @param array $ranges
195      * @return array
196      */
197     protected function calculate_indicators($sampleids, $samplesorigin, $indicators, $ranges) {
199         $dataset = array();
201         // Fill the dataset samples with indicators data.
202         foreach ($indicators as $indicator) {
204             // Hook to allow indicators to store analysable-dependant data.
205             $indicator->fill_per_analysable_caches($this->analysable);
207             // Per-range calculations.
208             foreach ($ranges as $rangeindex => $range) {
210                 // Indicator instances are per-range.
211                 $rangeindicator = clone $indicator;
213                 // Calculate the indicator for each sample in this time range.
214                 $calculated = $rangeindicator->calculate($sampleids, $samplesorigin, $range['start'], $range['end']);
216                 // Copy the calculated data to the dataset.
217                 foreach ($calculated as $analysersampleid => $calculatedvalues) {
219                     $uniquesampleid = $this->append_rangeindex($analysersampleid, $rangeindex);
221                     // Init the sample if it is still empty.
222                     if (!isset($dataset[$uniquesampleid])) {
223                         $dataset[$uniquesampleid] = array();
224                     }
226                     // Append the calculated indicator features at the end of the sample.
227                     $dataset[$uniquesampleid] = array_merge($dataset[$uniquesampleid], $calculatedvalues);
228                 }
229             }
230         }
232         return $dataset;
233     }
235     /**
236      * Adds time range indicators and the target to each sample.
237      *
238      * This will identify the sample as belonging to a specific range.
239      *
240      * @param array $dataset
241      * @param array $calculatedtarget
242      * @return void
243      */
244     protected function fill_dataset(&$dataset, $calculatedtarget = false) {
246         $nranges = count($this->get_all_ranges());
248         foreach ($dataset as $uniquesampleid => $unmodified) {
250             list($analysersampleid, $rangeindex) = $this->infer_sample_info($uniquesampleid);
252             // No need to add range features if this time splitting method only defines one time range.
253             if ($nranges > 1) {
255                 // 1 column for each range.
256                 $timeindicators = array_fill(0, $nranges, 0);
258                 $timeindicators[$rangeindex] = 1;
260                 $dataset[$uniquesampleid] = array_merge($timeindicators, $dataset[$uniquesampleid]);
261             }
263             if ($calculatedtarget) {
264                 // Add this sampleid's calculated target and the end.
265                 $dataset[$uniquesampleid][] = $calculatedtarget[$analysersampleid];
267             } else {
268                 // Add this sampleid, it will be used to identify the prediction that comes back from
269                 // the predictions processor.
270                 array_unshift($dataset[$uniquesampleid], $uniquesampleid);
271             }
272         }
273     }
275     /**
276      * Adds dataset context info.
277      *
278      * The final dataset document will look like this:
279      * ----------------------------------------------------
280      * metadata1,metadata2,metadata3,.....
281      * value1, value2, value3,.....
282      *
283      * indicator1,indicator2,indicator3,indicator4,.....
284      * stud1value1,stud1value2,stud1value3,stud1value4,.....
285      * stud2value1,stud2value2,stud2value3,stud2value4,.....
286      * .....
287      * ----------------------------------------------------
288      *
289      * @param array $dataset
290      * @param \core_analytics\local\indicator\base[] $indicators
291      * @param \core_analytics\local\target\base|false $target
292      * @return void
293      */
294     protected function add_metadata(&$dataset, $indicators, $target = false) {
296         $metadata = array(
297             'timesplitting' => $this->get_id(),
298             // If no target the first column is the sampleid, if target the last column is the target.
299             'nfeatures' => count(current($dataset)) - 1
300         );
301         if ($target) {
302             $metadata['targetclasses'] = json_encode($target::get_classes());
303             $metadata['targettype'] = ($target->is_linear()) ? 'linear' : 'discrete';
304         }
306         // The first 2 samples will be used to store metadata about the dataset.
307         $metadatacolumns = [];
308         $metadatavalues = [];
309         foreach ($metadata as $key => $value) {
310             $metadatacolumns[] = $key;
311             $metadatavalues[] = $value;
312         }
314         $headers = $this->get_headers($indicators, $target);
316         // This will also reset samples' dataset keys.
317         array_unshift($dataset, $metadatacolumns, $metadatavalues, $headers);
318     }
320     /**
321      * Returns the ranges used by this time splitting method.
322      *
323      * @return array
324      */
325     public function get_all_ranges() {
326         return $this->ranges;
327     }
329     /**
330      * Returns range data by its index.
331      *
332      * @param int $rangeindex
333      * @return array|false Range data or false if the index is not part of the existing ranges.
334      */
335     public function get_range_by_index($rangeindex) {
336         if (!isset($this->ranges[$rangeindex])) {
337             return false;
338         }
339         return $this->ranges[$rangeindex];
340     }
342     /**
343      * Generates a unique sample id (sample in a range index).
344      *
345      * @param int $sampleid
346      * @param int $rangeindex
347      * @return string
348      */
349     public function append_rangeindex($sampleid, $rangeindex) {
350         return $sampleid . '-' . $rangeindex;
351     }
353     /**
354      * Returns the sample id and the range index from a uniquesampleid.
355      *
356      * @param string $uniquesampleid
357      * @return array array($sampleid, $rangeindex)
358      */
359     public function infer_sample_info($uniquesampleid) {
360         return explode('-', $uniquesampleid);
361     }
363     /**
364      * Returns the headers for the csv file based on the indicators and the target.
365      *
366      * @param \core_analytics\local\indicator\base[] $indicators
367      * @param \core_analytics\local\target\base|false $target
368      * @return string[]
369      */
370     protected function get_headers($indicators, $target = false) {
371         // 3rd column will contain the indicator ids.
372         $headers = array();
374         if (!$target) {
375             // The first column is the sampleid.
376             $headers[] = 'sampleid';
377         }
379         // We always have 1 column for each time splitting method range, it does not depend on how
380         // many ranges we calculated.
381         $ranges = $this->get_all_ranges();
382         if (count($ranges) > 1) {
383             foreach ($ranges as $rangeindex => $range) {
384                 $headers[] = 'range/' . $rangeindex;
385             }
386         }
388         // Model indicators.
389         foreach ($indicators as $indicator) {
390             $headers = array_merge($headers, $indicator::get_feature_headers());
391         }
393         // The target as well.
394         if ($target) {
395             $headers[] = $target->get_id();
396         }
398         return $headers;
399     }
401     /**
402      * Validates the time splitting method ranges.
403      *
404      * @throws \coding_exception
405      * @return void
406      */
407     protected function validate_ranges() {
408         foreach ($this->ranges as $key => $range) {
409             if (!isset($this->ranges[$key]['start']) || !isset($this->ranges[$key]['end']) ||
410                     !isset($this->ranges[$key]['time'])) {
411                 throw new \coding_exception($this->get_id() . ' time splitting method "' . $key .
412                     '" range is not fully defined. We need a start timestamp and an end timestamp.');
413             }
414         }
415     }