MDL-64994 analytics: Improve the Python package version check
[moodle.git] / lib / mlbackend / python / 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  * Python predictions processor
19  *
20  * @package   mlbackend_python
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_python;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Python predictions processor.
31  *
32  * @package   mlbackend_python
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 class processor implements  \core_analytics\classifier, \core_analytics\regressor, \core_analytics\packable {
38     /**
39      * The required version of the python package that performs all calculations.
40      */
41     const REQUIRED_PIP_PACKAGE_VERSION = '1.0.0';
43     /**
44      * The path to the Python bin.
45      *
46      * @var string
47      */
48     protected $pathtopython;
50     /**
51      * The constructor.
52      */
53     public function __construct() {
54         global $CFG;
56         // Set the python location if there is a value.
57         if (!empty($CFG->pathtopython)) {
58             $this->pathtopython = $CFG->pathtopython;
59         }
60     }
62     /**
63      * Is the plugin ready to be used?.
64      *
65      * @return bool|string Returns true on success, a string detailing the error otherwise
66      */
67     public function is_ready() {
68         if (empty($this->pathtopython)) {
69             $settingurl = new \moodle_url('/admin/settings.php', array('section' => 'systempaths'));
70             return get_string('pythonpathnotdefined', 'mlbackend_python', $settingurl->out());
71         }
73         // Check the installed pip package version.
74         $cmd = "{$this->pathtopython} -m moodlemlbackend.version";
76         $output = null;
77         $exitcode = null;
78         // Execute it sending the standard error to $output.
79         $result = exec($cmd . ' 2>&1', $output, $exitcode);
81         $vercheck = self::check_pip_package_version($result);
83         if ($vercheck === 0) {
84             return true;
85         }
87         if ($exitcode != 0) {
88             return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd);
89         }
91         if ($result) {
92             $a = [
93                 'installed' => $result,
94                 'required' => self::REQUIRED_PIP_PACKAGE_VERSION,
95             ];
97             if ($vercheck < 0) {
98                 return get_string('packageinstalledshouldbe', 'mlbackend_python', $a);
100             } else if ($vercheck > 0) {
101                 return get_string('packageinstalledtoohigh', 'mlbackend_python', $a);
102             }
103         }
105         return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd);
106     }
108     /**
109      * Delete the model version output directory.
110      *
111      * @param string $uniqueid
112      * @param string $modelversionoutputdir
113      * @return null
114      */
115     public function clear_model($uniqueid, $modelversionoutputdir) {
116         remove_dir($modelversionoutputdir);
117     }
119     /**
120      * Delete the model output directory.
121      *
122      * @param string $modeloutputdir
123      * @return null
124      */
125     public function delete_output_dir($modeloutputdir) {
126         remove_dir($modeloutputdir);
127     }
129     /**
130      * Trains a machine learning algorithm with the provided dataset.
131      *
132      * @param string $uniqueid
133      * @param \stored_file $dataset
134      * @param string $outputdir
135      * @return \stdClass
136      */
137     public function train_classification($uniqueid, \stored_file $dataset, $outputdir) {
139         // Obtain the physical route to the file.
140         $datasetpath = $this->get_file_path($dataset);
142         $cmd = "{$this->pathtopython} -m moodlemlbackend.training " .
143             escapeshellarg($uniqueid) . ' ' .
144             escapeshellarg($outputdir) . ' ' .
145             escapeshellarg($datasetpath);
147         if (!PHPUNIT_TEST && CLI_SCRIPT) {
148             debugging($cmd, DEBUG_DEVELOPER);
149         }
151         $output = null;
152         $exitcode = null;
153         $result = exec($cmd, $output, $exitcode);
155         if (!$result) {
156             throw new \moodle_exception('errornopredictresults', 'analytics');
157         }
159         if (!$resultobj = json_decode($result)) {
160             throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
161         }
163         if ($exitcode != 0) {
164             if (!empty($resultobj->errors)) {
165                 $errors = $resultobj->errors;
166                 if (is_array($errors)) {
167                     $errors = implode(', ', $errors);
168                 }
169             } else if (!empty($resultobj->info)) {
170                 // Show info if no errors are returned.
171                 $errors = $resultobj->info;
172                 if (is_array($errors)) {
173                     $errors = implode(', ', $errors);
174                 }
175             }
176             $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors));
177         }
179         return $resultobj;
180     }
182     /**
183      * Classifies the provided dataset samples.
184      *
185      * @param string $uniqueid
186      * @param \stored_file $dataset
187      * @param string $outputdir
188      * @return \stdClass
189      */
190     public function classify($uniqueid, \stored_file $dataset, $outputdir) {
192         // Obtain the physical route to the file.
193         $datasetpath = $this->get_file_path($dataset);
195         $cmd = "{$this->pathtopython} -m moodlemlbackend.prediction " .
196             escapeshellarg($uniqueid) . ' ' .
197             escapeshellarg($outputdir) . ' ' .
198             escapeshellarg($datasetpath);
200         if (!PHPUNIT_TEST && CLI_SCRIPT) {
201             debugging($cmd, DEBUG_DEVELOPER);
202         }
204         $output = null;
205         $exitcode = null;
206         $result = exec($cmd, $output, $exitcode);
208         if (!$result) {
209             throw new \moodle_exception('errornopredictresults', 'analytics');
210         }
212         if (!$resultobj = json_decode($result)) {
213             throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
214         }
216         if ($exitcode != 0) {
217             if (!empty($resultobj->errors)) {
218                 $errors = $resultobj->errors;
219                 if (is_array($errors)) {
220                     $errors = implode(', ', $errors);
221                 }
222             } else if (!empty($resultobj->info)) {
223                 // Show info if no errors are returned.
224                 $errors = $resultobj->info;
225                 if (is_array($errors)) {
226                     $errors = implode(', ', $errors);
227                 }
228             }
229             $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors));
230         }
232         return $resultobj;
233     }
235     /**
236      * Evaluates this processor classification model using the provided supervised learning dataset.
237      *
238      * @param string $uniqueid
239      * @param float $maxdeviation
240      * @param int $niterations
241      * @param \stored_file $dataset
242      * @param string $outputdir
243      * @param  string $trainedmodeldir
244      * @return \stdClass
245      */
246     public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
247             $outputdir, $trainedmodeldir) {
249         // Obtain the physical route to the file.
250         $datasetpath = $this->get_file_path($dataset);
252         $cmd = "{$this->pathtopython} -m moodlemlbackend.evaluation " .
253             escapeshellarg($uniqueid) . ' ' .
254             escapeshellarg($outputdir) . ' ' .
255             escapeshellarg($datasetpath) . ' ' .
256             escapeshellarg(\core_analytics\model::MIN_SCORE) . ' ' .
257             escapeshellarg($maxdeviation) . ' ' .
258             escapeshellarg($niterations);
260         if ($trainedmodeldir) {
261             $cmd .= ' ' . escapeshellarg($trainedmodeldir);
262         }
264         if (!PHPUNIT_TEST && CLI_SCRIPT) {
265             debugging($cmd, DEBUG_DEVELOPER);
266         }
268         $output = null;
269         $exitcode = null;
270         $result = exec($cmd, $output, $exitcode);
272         if (!$result) {
273             throw new \moodle_exception('errornopredictresults', 'analytics');
274         }
276         if (!$resultobj = json_decode($result)) {
277             throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
278         }
280         return $resultobj;
281     }
283     /**
284      * Exports the machine learning model.
285      *
286      * @throws \moodle_exception
287      * @param  string $uniqueid  The model unique id
288      * @param  string $modeldir  The directory that contains the trained model.
289      * @return string            The path to the directory that contains the exported model.
290      */
291     public function export(string $uniqueid, string $modeldir) : string {
293         // We include an exporttmpdir as we want to be sure that the file is not deleted after the
294         // python process finishes.
295         $exporttmpdir = make_request_directory('mlbackend_python_export');
297         $cmd = "{$this->pathtopython} -m moodlemlbackend.export " .
298             escapeshellarg($uniqueid) . ' ' .
299             escapeshellarg($modeldir) . ' ' .
300             escapeshellarg($exporttmpdir);
302         if (!PHPUNIT_TEST && CLI_SCRIPT) {
303             debugging($cmd, DEBUG_DEVELOPER);
304         }
306         $output = null;
307         $exitcode = null;
308         $exportdir = exec($cmd, $output, $exitcode);
310         if ($exitcode != 0) {
311             throw new \moodle_exception('errorexportmodelresult', 'analytics');
312         }
314         if (!$exportdir) {
315             throw new \moodle_exception('errorexportmodelresult', 'analytics');
316         }
318         return $exportdir;
319     }
321     /**
322      * Imports the provided machine learning model.
323      *
324      * @param  string $uniqueid The model unique id
325      * @param  string $modeldir  The directory that will contain the trained model.
326      * @param  string $importdir The directory that contains the files to import.
327      * @return bool Success
328      */
329     public function import(string $uniqueid, string $modeldir, string $importdir) : bool {
331         $cmd = "{$this->pathtopython} -m moodlemlbackend.import " .
332             escapeshellarg($uniqueid) . ' ' .
333             escapeshellarg($modeldir) . ' ' .
334             escapeshellarg($importdir);
336         if (!PHPUNIT_TEST && CLI_SCRIPT) {
337             debugging($cmd, DEBUG_DEVELOPER);
338         }
340         $output = null;
341         $exitcode = null;
342         $success = exec($cmd, $output, $exitcode);
344         if ($exitcode != 0) {
345             throw new \moodle_exception('errorimportmodelresult', 'analytics');
346         }
348         if (!$success) {
349             throw new \moodle_exception('errorimportmodelresult', 'analytics');
350         }
352         return $success;
353     }
355     /**
356      * Train this processor regression model using the provided supervised learning dataset.
357      *
358      * @throws new \coding_exception
359      * @param string $uniqueid
360      * @param \stored_file $dataset
361      * @param string $outputdir
362      * @return \stdClass
363      */
364     public function train_regression($uniqueid, \stored_file $dataset, $outputdir) {
365         throw new \coding_exception('This predictor does not support regression yet.');
366     }
368     /**
369      * Estimates linear values for the provided dataset samples.
370      *
371      * @throws new \coding_exception
372      * @param string $uniqueid
373      * @param \stored_file $dataset
374      * @param mixed $outputdir
375      * @return void
376      */
377     public function estimate($uniqueid, \stored_file $dataset, $outputdir) {
378         throw new \coding_exception('This predictor does not support regression yet.');
379     }
381     /**
382      * Evaluates this processor regression model using the provided supervised learning dataset.
383      *
384      * @throws new \coding_exception
385      * @param string $uniqueid
386      * @param float $maxdeviation
387      * @param int $niterations
388      * @param \stored_file $dataset
389      * @param string $outputdir
390      * @param  string $trainedmodeldir
391      * @return \stdClass
392      */
393     public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
394             $outputdir, $trainedmodeldir) {
395         throw new \coding_exception('This predictor does not support regression yet.');
396     }
398     /**
399      * Returns the path to the dataset file.
400      *
401      * @param \stored_file $file
402      * @return string
403      */
404     protected function get_file_path(\stored_file $file) {
405         // From moodle filesystem to the local file system.
406         // This is not ideal, but there is no read access to moodle filesystem files.
407         return $file->copy_content_to_temp('core_analytics');
408     }
410     /**
411      * Check that the given package version can be used and return the error status.
412      *
413      * When evaluating the version, we assume the sematic versioning scheme as described at
414      * https://semver.org/.
415      *
416      * @param string $actual The actual Python package version
417      * @param string $required The required version of the package
418      * @return int -1 = actual version is too low, 1 = actual version too high, 0 = actual version is ok
419      */
420     public static function check_pip_package_version($actual, $required = self::REQUIRED_PIP_PACKAGE_VERSION) {
422         if (empty($actual)) {
423             return -1;
424         }
426         if (version_compare($actual, $required, '<')) {
427             return -1;
428         }
430         $parts = explode('.', $required);
431         $requiredapiver = reset($parts);
433         $parts = explode('.', $actual);
434         $actualapiver = reset($parts);
436         if ($requiredapiver > 0 || $actualapiver > 1) {
437             if (version_compare($actual, $requiredapiver + 1, '>=')) {
438                 return 1;
439             }
440         }
442         return 0;
443     }