MDL-64994 analytics: Improve the Python package version check
[moodle.git] / lib / mlbackend / python / classes / processor.php
CommitLineData
25531de6
DM
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/>.
16
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 */
24
25namespace mlbackend_python;
26
27defined('MOODLE_INTERNAL') || die();
28
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 */
c70a7194 36class processor implements \core_analytics\classifier, \core_analytics\regressor, \core_analytics\packable {
25531de6 37
413f19bc
DM
38 /**
39 * The required version of the python package that performs all calculations.
40 */
dea8ab89 41 const REQUIRED_PIP_PACKAGE_VERSION = '1.0.0';
25531de6 42
391663ce
MN
43 /**
44 * The path to the Python bin.
45 *
46 * @var string
47 */
48 protected $pathtopython;
49
50 /**
51 * The constructor.
52 */
53 public function __construct() {
54 global $CFG;
55
56 // Set the python location if there is a value.
57 if (!empty($CFG->pathtopython)) {
58 $this->pathtopython = $CFG->pathtopython;
59 }
60 }
61
413f19bc
DM
62 /**
63 * Is the plugin ready to be used?.
64 *
391663ce 65 * @return bool|string Returns true on success, a string detailing the error otherwise
413f19bc 66 */
25531de6 67 public function is_ready() {
391663ce
MN
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 }
25531de6 72
413f19bc 73 // Check the installed pip package version.
391663ce 74 $cmd = "{$this->pathtopython} -m moodlemlbackend.version";
25531de6
DM
75
76 $output = null;
77 $exitcode = null;
78 // Execute it sending the standard error to $output.
79 $result = exec($cmd . ' 2>&1', $output, $exitcode);
80
964ccd96
DM
81 $vercheck = self::check_pip_package_version($result);
82
83 if ($vercheck === 0) {
25531de6
DM
84 return true;
85 }
86
87 if ($exitcode != 0) {
88 return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd);
89 }
90
91 if ($result) {
964ccd96
DM
92 $a = [
93 'installed' => $result,
94 'required' => self::REQUIRED_PIP_PACKAGE_VERSION,
95 ];
96
97 if ($vercheck < 0) {
98 return get_string('packageinstalledshouldbe', 'mlbackend_python', $a);
99
100 } else if ($vercheck > 0) {
101 return get_string('packageinstalledtoohigh', 'mlbackend_python', $a);
102 }
25531de6
DM
103 }
104
105 return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd);
106 }
107
abafbc84
DM
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 }
118
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 }
128
413f19bc
DM
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 */
5c5cb3ee 137 public function train_classification($uniqueid, \stored_file $dataset, $outputdir) {
25531de6 138
413f19bc 139 // Obtain the physical route to the file.
25531de6
DM
140 $datasetpath = $this->get_file_path($dataset);
141
391663ce 142 $cmd = "{$this->pathtopython} -m moodlemlbackend.training " .
25531de6
DM
143 escapeshellarg($uniqueid) . ' ' .
144 escapeshellarg($outputdir) . ' ' .
145 escapeshellarg($datasetpath);
146
147 if (!PHPUNIT_TEST && CLI_SCRIPT) {
148 debugging($cmd, DEBUG_DEVELOPER);
149 }
150
151 $output = null;
152 $exitcode = null;
153 $result = exec($cmd, $output, $exitcode);
154
155 if (!$result) {
156 throw new \moodle_exception('errornopredictresults', 'analytics');
157 }
158
159 if (!$resultobj = json_decode($result)) {
160 throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
161 }
162
163 if ($exitcode != 0) {
0af2421a
DM
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 }
325b3bdd
DM
175 }
176 $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors));
25531de6
DM
177 }
178
179 return $resultobj;
180 }
181
413f19bc 182 /**
5c5cb3ee 183 * Classifies the provided dataset samples.
413f19bc
DM
184 *
185 * @param string $uniqueid
186 * @param \stored_file $dataset
187 * @param string $outputdir
188 * @return \stdClass
189 */
5c5cb3ee 190 public function classify($uniqueid, \stored_file $dataset, $outputdir) {
25531de6 191
413f19bc 192 // Obtain the physical route to the file.
25531de6
DM
193 $datasetpath = $this->get_file_path($dataset);
194
391663ce 195 $cmd = "{$this->pathtopython} -m moodlemlbackend.prediction " .
25531de6
DM
196 escapeshellarg($uniqueid) . ' ' .
197 escapeshellarg($outputdir) . ' ' .
198 escapeshellarg($datasetpath);
199
200 if (!PHPUNIT_TEST && CLI_SCRIPT) {
201 debugging($cmd, DEBUG_DEVELOPER);
202 }
203
204 $output = null;
205 $exitcode = null;
206 $result = exec($cmd, $output, $exitcode);
207
208 if (!$result) {
209 throw new \moodle_exception('errornopredictresults', 'analytics');
210 }
211
212 if (!$resultobj = json_decode($result)) {
213 throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
214 }
215
216 if ($exitcode != 0) {
0af2421a
DM
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 }
325b3bdd
DM
228 }
229 $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors));
25531de6
DM
230 }
231
232 return $resultobj;
233 }
234
413f19bc 235 /**
5c5cb3ee 236 * Evaluates this processor classification model using the provided supervised learning dataset.
413f19bc
DM
237 *
238 * @param string $uniqueid
239 * @param float $maxdeviation
240 * @param int $niterations
241 * @param \stored_file $dataset
242 * @param string $outputdir
bc82b895 243 * @param string $trainedmodeldir
413f19bc
DM
244 * @return \stdClass
245 */
bc82b895
DM
246 public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
247 $outputdir, $trainedmodeldir) {
25531de6 248
413f19bc 249 // Obtain the physical route to the file.
25531de6
DM
250 $datasetpath = $this->get_file_path($dataset);
251
391663ce 252 $cmd = "{$this->pathtopython} -m moodlemlbackend.evaluation " .
25531de6
DM
253 escapeshellarg($uniqueid) . ' ' .
254 escapeshellarg($outputdir) . ' ' .
255 escapeshellarg($datasetpath) . ' ' .
256 escapeshellarg(\core_analytics\model::MIN_SCORE) . ' ' .
257 escapeshellarg($maxdeviation) . ' ' .
258 escapeshellarg($niterations);
259
bc82b895
DM
260 if ($trainedmodeldir) {
261 $cmd .= ' ' . escapeshellarg($trainedmodeldir);
262 }
263
25531de6
DM
264 if (!PHPUNIT_TEST && CLI_SCRIPT) {
265 debugging($cmd, DEBUG_DEVELOPER);
266 }
267
268 $output = null;
269 $exitcode = null;
270 $result = exec($cmd, $output, $exitcode);
271
272 if (!$result) {
273 throw new \moodle_exception('errornopredictresults', 'analytics');
274 }
275
276 if (!$resultobj = json_decode($result)) {
277 throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
278 }
279
280 return $resultobj;
281 }
282
c70a7194
DM
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 {
292
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');
296
297 $cmd = "{$this->pathtopython} -m moodlemlbackend.export " .
298 escapeshellarg($uniqueid) . ' ' .
299 escapeshellarg($modeldir) . ' ' .
300 escapeshellarg($exporttmpdir);
301
302 if (!PHPUNIT_TEST && CLI_SCRIPT) {
303 debugging($cmd, DEBUG_DEVELOPER);
304 }
305
306 $output = null;
307 $exitcode = null;
308 $exportdir = exec($cmd, $output, $exitcode);
309
310 if ($exitcode != 0) {
311 throw new \moodle_exception('errorexportmodelresult', 'analytics');
312 }
313
314 if (!$exportdir) {
315 throw new \moodle_exception('errorexportmodelresult', 'analytics');
316 }
317
318 return $exportdir;
319 }
320
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 {
330
331 $cmd = "{$this->pathtopython} -m moodlemlbackend.import " .
332 escapeshellarg($uniqueid) . ' ' .
333 escapeshellarg($modeldir) . ' ' .
334 escapeshellarg($importdir);
335
336 if (!PHPUNIT_TEST && CLI_SCRIPT) {
337 debugging($cmd, DEBUG_DEVELOPER);
338 }
339
340 $output = null;
341 $exitcode = null;
342 $success = exec($cmd, $output, $exitcode);
343
344 if ($exitcode != 0) {
345 throw new \moodle_exception('errorimportmodelresult', 'analytics');
346 }
347
348 if (!$success) {
349 throw new \moodle_exception('errorimportmodelresult', 'analytics');
350 }
351
352 return $success;
353 }
354
5c5cb3ee
DM
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 }
367
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 }
380
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
bc82b895 390 * @param string $trainedmodeldir
5c5cb3ee
DM
391 * @return \stdClass
392 */
bc82b895
DM
393 public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
394 $outputdir, $trainedmodeldir) {
5c5cb3ee
DM
395 throw new \coding_exception('This predictor does not support regression yet.');
396 }
397
413f19bc
DM
398 /**
399 * Returns the path to the dataset file.
400 *
401 * @param \stored_file $file
402 * @return string
403 */
25531de6
DM
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 }
2d8405e9
DM
409
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) {
421
422 if (empty($actual)) {
423 return -1;
424 }
425
426 if (version_compare($actual, $required, '<')) {
427 return -1;
428 }
429
430 $parts = explode('.', $required);
431 $requiredapiver = reset($parts);
432
433 $parts = explode('.', $actual);
434 $actualapiver = reset($parts);
435
436 if ($requiredapiver > 0 || $actualapiver > 1) {
437 if (version_compare($actual, $requiredapiver + 1, '>=')) {
438 return 1;
439 }
440 }
441
442 return 0;
443 }
25531de6 444}