MDL-59630 analytics: mlbackend model directories deletion
[moodle.git] / lib / mlbackend / php / classes / processor.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  * Php predictions processor
19  *
20  * @package   mlbackend_php
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 mlbackend_php;
27 defined('MOODLE_INTERNAL') || die();
29 use Phpml\Preprocessing\Normalizer;
30 use Phpml\CrossValidation\RandomSplit;
31 use Phpml\Dataset\ArrayDataset;
32 use Phpml\ModelManager;
34 /**
35  * PHP predictions processor.
36  *
37  * @package   mlbackend_php
38  * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
39  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40  */
41 class processor implements \core_analytics\classifier, \core_analytics\regressor {
43     /**
44      * Size of training / prediction batches.
45      */
46     const BATCH_SIZE = 5000;
48     /**
49      * Number of train iterations.
50      */
51     const TRAIN_ITERATIONS = 500;
53     /**
54      * File name of the serialised model.
55      */
56     const MODEL_FILENAME = 'model.ser';
58     /**
59      * @var bool
60      */
61     protected $limitedsize = false;
63     /**
64      * Checks if the processor is ready to use.
65      *
66      * @return bool
67      */
68     public function is_ready() {
69         if (version_compare(phpversion(), '7.0.0') < 0) {
70             return get_string('errorphp7required', 'mlbackend_php');
71         }
72         return true;
73     }
75     /**
76      * Delete the stored models.
77      *
78      * @param string $uniqueid
79      * @param string $modelversionoutputdir
80      * @return null
81      */
82     public function clear_model($uniqueid, $modelversionoutputdir) {
83         remove_dir($modelversionoutputdir);
84     }
86     /**
87      * Delete the output directory.
88      *
89      * @param string $modeloutputdir
90      * @return null
91      */
92     public function delete_output_dir($modeloutputdir) {
93         remove_dir($modeloutputdir);
94     }
96     /**
97      * Train this processor classification model using the provided supervised learning dataset.
98      *
99      * @param string $uniqueid
100      * @param \stored_file $dataset
101      * @param string $outputdir
102      * @return \stdClass
103      */
104     public function train_classification($uniqueid, \stored_file $dataset, $outputdir) {
106         // Output directory is already unique to the model.
107         $modelfilepath = $outputdir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME;
109         $modelmanager = new ModelManager();
111         if (file_exists($modelfilepath)) {
112             $classifier = $modelmanager->restoreFromFile($modelfilepath);
113         } else {
114             $classifier = new \Phpml\Classification\Linear\LogisticRegression(self::TRAIN_ITERATIONS, Normalizer::NORM_L2);
115         }
117         $fh = $dataset->get_content_file_handle();
119         // The first lines are var names and the second one values.
120         $metadata = $this->extract_metadata($fh);
122         // Skip headers.
123         fgets($fh);
125         $samples = array();
126         $targets = array();
127         while (($data = fgetcsv($fh)) !== false) {
128             $sampledata = array_map('floatval', $data);
129             $samples[] = array_slice($sampledata, 0, $metadata['nfeatures']);
130             $targets[] = intval($data[$metadata['nfeatures']]);
132             if (count($samples) === self::BATCH_SIZE) {
133                 // Training it batches to avoid running out of memory.
135                 $classifier->partialTrain($samples, $targets, array(0, 1));
136                 $samples = array();
137                 $targets = array();
138             }
139         }
140         fclose($fh);
142         // Train the remaining samples.
143         if ($samples) {
144             $classifier->partialTrain($samples, $targets, array(0, 1));
145         }
147         $resultobj = new \stdClass();
148         $resultobj->status = \core_analytics\model::OK;
149         $resultobj->info = array();
151         // Store the trained model.
152         $modelmanager->saveToFile($classifier, $modelfilepath);
154         return $resultobj;
155     }
157     /**
158      * Classifies the provided dataset samples.
159      *
160      * @param string $uniqueid
161      * @param \stored_file $dataset
162      * @param string $outputdir
163      * @return \stdClass
164      */
165     public function classify($uniqueid, \stored_file $dataset, $outputdir) {
167         // Output directory is already unique to the model.
168         $modelfilepath = $outputdir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME;
170         if (!file_exists($modelfilepath)) {
171             throw new \moodle_exception('errorcantloadmodel', 'mlbackend_php', '', $modelfilepath);
172         }
174         $modelmanager = new ModelManager();
175         $classifier = $modelmanager->restoreFromFile($modelfilepath);
177         $fh = $dataset->get_content_file_handle();
179         // The first lines are var names and the second one values.
180         $metadata = $this->extract_metadata($fh);
182         // Skip headers.
183         fgets($fh);
185         $sampleids = array();
186         $samples = array();
187         $predictions = array();
188         while (($data = fgetcsv($fh)) !== false) {
189             $sampledata = array_map('floatval', $data);
190             $sampleids[] = $data[0];
191             $samples[] = array_slice($sampledata, 1, $metadata['nfeatures']);
193             if (count($samples) === self::BATCH_SIZE) {
194                 // Prediction it batches to avoid running out of memory.
196                 // Append predictions incrementally, we want $sampleids keys in sync with $predictions keys.
197                 $newpredictions = $classifier->predict($samples);
198                 foreach ($newpredictions as $prediction) {
199                     array_push($predictions, $prediction);
200                 }
201                 $samples = array();
202             }
203         }
204         fclose($fh);
206         // Finish the remaining predictions.
207         if ($samples) {
208             $predictions = $predictions + $classifier->predict($samples);
209         }
211         $resultobj = new \stdClass();
212         $resultobj->status = \core_analytics\model::OK;
213         $resultobj->info = array();
215         foreach ($predictions as $index => $prediction) {
216             $resultobj->predictions[$index] = array($sampleids[$index], $prediction);
217         }
219         return $resultobj;
220     }
222     /**
223      * Evaluates this processor classification model using the provided supervised learning dataset.
224      *
225      * During evaluation we need to shuffle the evaluation dataset samples to detect deviated results,
226      * if the dataset is massive we can not load everything into memory. We know that 2GB is the
227      * minimum memory limit we should have (\core_analytics\model::heavy_duty_mode), if we substract the memory
228      * that we already consumed and the memory that Phpml algorithms will need we should still have at
229      * least 500MB of memory, which should be enough to evaluate a model. In any case this is a robust
230      * solution that will work for all sites but it should minimize memory limit problems. Site admins
231      * can still set $CFG->mlbackend_php_no_evaluation_limits to true to skip this 500MB limit.
232      *
233      * @param string $uniqueid
234      * @param float $maxdeviation
235      * @param int $niterations
236      * @param \stored_file $dataset
237      * @param string $outputdir
238      * @return \stdClass
239      */
240     public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
241         $fh = $dataset->get_content_file_handle();
243         // The first lines are var names and the second one values.
244         $metadata = $this->extract_metadata($fh);
246         // Skip headers.
247         fgets($fh);
249         if (empty($CFG->mlbackend_php_no_evaluation_limits)) {
250             $samplessize = 0;
251             $limit = get_real_size('500MB');
253             // Just an approximation, will depend on PHP version, compile options...
254             // Double size + zval struct (6 bytes + 8 bytes + 16 bytes) + array bucket (96 bytes)
255             // https://nikic.github.io/2011/12/12/How-big-are-PHP-arrays-really-Hint-BIG.html.
256             $floatsize = (PHP_INT_SIZE * 2) + 6 + 8 + 16 + 96;
257         }
259         $samples = array();
260         $targets = array();
261         while (($data = fgetcsv($fh)) !== false) {
262             $sampledata = array_map('floatval', $data);
264             $samples[] = array_slice($sampledata, 0, $metadata['nfeatures']);
265             $targets[] = intval($data[$metadata['nfeatures']]);
267             if (empty($CFG->mlbackend_php_no_evaluation_limits)) {
268                 // We allow admins to disable evaluation memory usage limits by modifying config.php.
270                 // We will have plenty of missing values in the dataset so it should be a conservative approximation.
271                 $samplessize = $samplessize + (count($sampledata) * $floatsize);
273                 // Stop fetching more samples.
274                 if ($samplessize >= $limit) {
275                     $this->limitedsize = true;
276                     break;
277                 }
278             }
279         }
280         fclose($fh);
282         // We need at least 2 samples belonging to each target.
283         $counts = array_count_values($targets);
284         foreach ($counts as $count) {
285             if ($count < 2) {
286                 $notenoughdata = true;
287             }
288         }
289         if (!empty($notenoughdata)) {
290             $resultobj = new \stdClass();
291             $resultobj->status = \core_analytics\model::EVALUATE_NOT_ENOUGH_DATA;
292             $resultobj->score = 0;
293             $resultobj->info = array(get_string('errornotenoughdata', 'mlbackend_php'));
294             return $resultobj;
295         }
297         $phis = array();
299         // Evaluate the model multiple times to confirm the results are not significantly random due to a short amount of data.
300         for ($i = 0; $i < $niterations; $i++) {
302             $classifier = new \Phpml\Classification\Linear\LogisticRegression(self::TRAIN_ITERATIONS, Normalizer::NORM_L2);
304             // Split up the dataset in classifier and testing.
305             $data = new RandomSplit(new ArrayDataset($samples, $targets), 0.2);
307             $classifier->train($data->getTrainSamples(), $data->getTrainLabels());
309             $predictedlabels = $classifier->predict($data->getTestSamples());
310             $phis[] = $this->get_phi($data->getTestLabels(), $predictedlabels);
311         }
313         // Let's fill the results changing the returned status code depending on the phi-related calculated metrics.
314         return $this->get_evaluation_result_object($dataset, $phis, $maxdeviation);
315     }
317     /**
318      * Returns the results objects from all evaluations.
319      *
320      * @param \stored_file $dataset
321      * @param array $phis
322      * @param float $maxdeviation
323      * @return \stdClass
324      */
325     protected function get_evaluation_result_object(\stored_file $dataset, $phis, $maxdeviation) {
327         // Average phi of all evaluations as final score.
328         if (count($phis) === 1) {
329             $avgphi = reset($phis);
330         } else {
331             $avgphi = \Phpml\Math\Statistic\Mean::arithmetic($phis);
332         }
334         // Standard deviation should ideally be calculated against the area under the curve.
335         if (count($phis) === 1) {
336             $modeldev = 0;
337         } else {
338             $modeldev = \Phpml\Math\Statistic\StandardDeviation::population($phis);
339         }
341         // Let's fill the results object.
342         $resultobj = new \stdClass();
344         // Zero is ok, now we add other bits if something is not right.
345         $resultobj->status = \core_analytics\model::OK;
346         $resultobj->info = array();
348         // Convert phi to a standard score (from -1 to 1 to a value between 0 and 1).
349         $resultobj->score = ($avgphi + 1) / 2;
351         // If each iteration results varied too much we need more data to confirm that this is a valid model.
352         if ($modeldev > $maxdeviation) {
353             $resultobj->status = $resultobj->status + \core_analytics\model::EVALUATE_NOT_ENOUGH_DATA;
354             $a = new \stdClass();
355             $a->deviation = $modeldev;
356             $a->accepteddeviation = $maxdeviation;
357             $resultobj->info[] = get_string('errornotenoughdatadev', 'mlbackend_php', $a);
358         }
360         if ($resultobj->score < \core_analytics\model::MIN_SCORE) {
361             $resultobj->status = $resultobj->status + \core_analytics\model::EVALUATE_LOW_SCORE;
362             $a = new \stdClass();
363             $a->score = $resultobj->score;
364             $a->minscore = \core_analytics\model::MIN_SCORE;
365             $resultobj->info[] = get_string('errorlowscore', 'mlbackend_php', $a);
366         }
368         if ($this->limitedsize === true) {
369             $resultobj->info[] = get_string('datasetsizelimited', 'mlbackend_php', display_size($dataset->get_filesize()));
370         }
372         return $resultobj;
373     }
375     /**
376      * Train this processor regression model using the provided supervised learning dataset.
377      *
378      * @throws new \coding_exception
379      * @param string $uniqueid
380      * @param \stored_file $dataset
381      * @param string $outputdir
382      * @return \stdClass
383      */
384     public function train_regression($uniqueid, \stored_file $dataset, $outputdir) {
385         throw new \coding_exception('This predictor does not support regression yet.');
386     }
388     /**
389      * Estimates linear values for the provided dataset samples.
390      *
391      * @throws new \coding_exception
392      * @param string $uniqueid
393      * @param \stored_file $dataset
394      * @param mixed $outputdir
395      * @return void
396      */
397     public function estimate($uniqueid, \stored_file $dataset, $outputdir) {
398         throw new \coding_exception('This predictor does not support regression yet.');
399     }
401     /**
402      * Evaluates this processor regression model using the provided supervised learning dataset.
403      *
404      * @throws new \coding_exception
405      * @param string $uniqueid
406      * @param float $maxdeviation
407      * @param int $niterations
408      * @param \stored_file $dataset
409      * @param string $outputdir
410      * @return \stdClass
411      */
412     public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, $outputdir) {
413         throw new \coding_exception('This predictor does not support regression yet.');
414     }
416     /**
417      * Returns the Phi correlation coefficient.
418      *
419      * @param array $testlabels
420      * @param array $predictedlabels
421      * @return float
422      */
423     protected function get_phi($testlabels, $predictedlabels) {
425         // Binary here only as well.
426         $matrix = \Phpml\Metric\ConfusionMatrix::compute($testlabels, $predictedlabels, array(0, 1));
428         $tptn = $matrix[0][0] * $matrix[1][1];
429         $fpfn = $matrix[1][0] * $matrix[0][1];
430         $tpfp = $matrix[0][0] + $matrix[1][0];
431         $tpfn = $matrix[0][0] + $matrix[0][1];
432         $tnfp = $matrix[1][1] + $matrix[1][0];
433         $tnfn = $matrix[1][1] + $matrix[0][1];
434         if ($tpfp === 0 || $tpfn === 0 || $tnfp === 0 || $tnfn === 0) {
435             $phi = 0;
436         } else {
437             $phi = ( $tptn - $fpfn ) / sqrt( $tpfp * $tpfn * $tnfp * $tnfn);
438         }
440         return $phi;
441     }
443     /**
444      * Extracts metadata from the dataset file.
445      *
446      * The file poiter should be located at the top of the file.
447      *
448      * @param resource $fh
449      * @return array
450      */
451     protected function extract_metadata($fh) {
452         $metadata = fgetcsv($fh);
453         return array_combine($metadata, fgetcsv($fh));
454     }