MDL-57791 analytics: Always absolute full class names
[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             // Per-range calculations.
205             foreach ($ranges as $rangeindex => $range) {
207                 // Indicator instances are per-range.
208                 $rangeindicator = clone $indicator;
210                 // Calculate the indicator for each sample in this time range.
211                 $calculated = $rangeindicator->calculate($sampleids, $samplesorigin, $range['start'], $range['end']);
213                 // Copy the calculated data to the dataset.
214                 foreach ($calculated as $analysersampleid => $calculatedvalues) {
216                     $uniquesampleid = $this->append_rangeindex($analysersampleid, $rangeindex);
218                     // Init the sample if it is still empty.
219                     if (!isset($dataset[$uniquesampleid])) {
220                         $dataset[$uniquesampleid] = array();
221                     }
223                     // Append the calculated indicator features at the end of the sample.
224                     $dataset[$uniquesampleid] = array_merge($dataset[$uniquesampleid], $calculatedvalues);
225                 }
226             }
227         }
229         return $dataset;
230     }
232     /**
233      * Adds time range indicators and the target to each sample.
234      *
235      * This will identify the sample as belonging to a specific range.
236      *
237      * @param array $dataset
238      * @param array $calculatedtarget
239      * @return void
240      */
241     protected function fill_dataset(&$dataset, $calculatedtarget = false) {
243         $nranges = count($this->get_all_ranges());
245         foreach ($dataset as $uniquesampleid => $unmodified) {
247             list($analysersampleid, $rangeindex) = $this->infer_sample_info($uniquesampleid);
249             // No need to add range features if this time splitting method only defines one time range.
250             if ($nranges > 1) {
252                 // 1 column for each range.
253                 $timeindicators = array_fill(0, $nranges, 0);
255                 $timeindicators[$rangeindex] = 1;
257                 $dataset[$uniquesampleid] = array_merge($timeindicators, $dataset[$uniquesampleid]);
258             }
260             if ($calculatedtarget) {
261                 // Add this sampleid's calculated target and the end.
262                 $dataset[$uniquesampleid][] = $calculatedtarget[$analysersampleid];
264             } else {
265                 // Add this sampleid, it will be used to identify the prediction that comes back from
266                 // the predictions processor.
267                 array_unshift($dataset[$uniquesampleid], $uniquesampleid);
268             }
269         }
270     }
272     /**
273      * Adds dataset context info.
274      *
275      * The final dataset document will look like this:
276      * ----------------------------------------------------
277      * metadata1,metadata2,metadata3,.....
278      * value1, value2, value3,.....
279      *
280      * indicator1,indicator2,indicator3,indicator4,.....
281      * stud1value1,stud1value2,stud1value3,stud1value4,.....
282      * stud2value1,stud2value2,stud2value3,stud2value4,.....
283      * .....
284      * ----------------------------------------------------
285      *
286      * @param array $dataset
287      * @param \core_analytics\local\indicator\base[] $indicators
288      * @param \core_analytics\local\target\base|false $target
289      * @return void
290      */
291     protected function add_metadata(&$dataset, $indicators, $target = false) {
293         $metadata = array(
294             'timesplitting' => $this->get_id(),
295             // If no target the first column is the sampleid, if target the last column is the target.
296             'nfeatures' => count(current($dataset)) - 1
297         );
298         if ($target) {
299             $metadata['targetclasses'] = json_encode($target::get_classes());
300             $metadata['targettype'] = ($target->is_linear()) ? 'linear' : 'discrete';
301         }
303         // The first 2 samples will be used to store metadata about the dataset.
304         $metadatacolumns = [];
305         $metadatavalues = [];
306         foreach ($metadata as $key => $value) {
307             $metadatacolumns[] = $key;
308             $metadatavalues[] = $value;
309         }
311         $headers = $this->get_headers($indicators, $target);
313         // This will also reset samples' dataset keys.
314         array_unshift($dataset, $metadatacolumns, $metadatavalues, $headers);
315     }
317     /**
318      * Returns the ranges used by this time splitting method.
319      *
320      * @return array
321      */
322     public function get_all_ranges() {
323         return $this->ranges;
324     }
326     /**
327      * Returns range data by its index.
328      *
329      * @param int $rangeindex
330      * @return array|false Range data or false if the index is not part of the existing ranges.
331      */
332     public function get_range_by_index($rangeindex) {
333         if (!isset($this->ranges[$rangeindex])) {
334             return false;
335         }
336         return $this->ranges[$rangeindex];
337     }
339     /**
340      * Generates a unique sample id (sample in a range index).
341      *
342      * @param int $sampleid
343      * @param int $rangeindex
344      * @return string
345      */
346     public function append_rangeindex($sampleid, $rangeindex) {
347         return $sampleid . '-' . $rangeindex;
348     }
350     /**
351      * Returns the sample id and the range index from a uniquesampleid.
352      *
353      * @param string $uniquesampleid
354      * @return array array($sampleid, $rangeindex)
355      */
356     public function infer_sample_info($uniquesampleid) {
357         return explode('-', $uniquesampleid);
358     }
360     /**
361      * Returns the headers for the csv file based on the indicators and the target.
362      *
363      * @param \core_analytics\local\indicator\base[] $indicators
364      * @param \core_analytics\local\target\base|false $target
365      * @return string[]
366      */
367     protected function get_headers($indicators, $target = false) {
368         // 3rd column will contain the indicator ids.
369         $headers = array();
371         if (!$target) {
372             // The first column is the sampleid.
373             $headers[] = 'sampleid';
374         }
376         // We always have 1 column for each time splitting method range, it does not depend on how
377         // many ranges we calculated.
378         $ranges = $this->get_all_ranges();
379         if (count($ranges) > 1) {
380             foreach ($ranges as $rangeindex => $range) {
381                 $headers[] = 'range/' . $rangeindex;
382             }
383         }
385         // Model indicators.
386         foreach ($indicators as $indicator) {
387             $headers = array_merge($headers, $indicator::get_feature_headers());
388         }
390         // The target as well.
391         if ($target) {
392             $headers[] = $target->get_id();
393         }
395         return $headers;
396     }
398     /**
399      * Validates the time splitting method ranges.
400      *
401      * @throws \coding_exception
402      * @return void
403      */
404     protected function validate_ranges() {
405         foreach ($this->ranges as $key => $range) {
406             if (!isset($this->ranges[$key]['start']) || !isset($this->ranges[$key]['end']) ||
407                     !isset($this->ranges[$key]['time'])) {
408                 throw new \coding_exception($this->get_id() . ' time splitting method "' . $key .
409                     '" range is not fully defined. We need a start timestamp and an end timestamp.');
410             }
411         }
412     }