See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401]
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 25 namespace mlbackend_python; 26 27 defined('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 */ 36 class processor implements \core_analytics\classifier, \core_analytics\regressor, \core_analytics\packable { 37 38 /** 39 * The required version of the python package that performs all calculations. 40 */ 41 const REQUIRED_PIP_PACKAGE_VERSION = '3.0.5'; 42 43 /** 44 * The python package is installed in a server. 45 * @var bool 46 */ 47 protected $useserver; 48 49 /** 50 * The path to the Python bin. 51 * 52 * @var string 53 */ 54 protected $pathtopython; 55 56 /** 57 * Remote server host 58 * @var string 59 */ 60 protected $host; 61 62 /** 63 * Remote server port 64 * @var int 65 */ 66 protected $port; 67 68 /** 69 * Whether to use http or https. 70 * @var bool 71 */ 72 protected $secure; 73 74 /** 75 * Server username. 76 * @var string 77 */ 78 protected $username; 79 80 /** 81 * Server password for $this->username. 82 * @var string 83 */ 84 protected $password; 85 86 /** 87 * The constructor. 88 * 89 */ 90 public function __construct() { 91 global $CFG; 92 93 $config = get_config('mlbackend_python'); 94 95 $this->useserver = !empty($config->useserver); 96 97 if (!$this->useserver) { 98 // Set the python location if there is a value. 99 if (!empty($CFG->pathtopython)) { 100 $this->pathtopython = $CFG->pathtopython; 101 } 102 } else { 103 $this->host = $config->host ?? ''; 104 $this->port = $config->port ?? ''; 105 $this->secure = $config->secure ?? false; 106 $this->username = $config->username ?? ''; 107 $this->password = $config->password ?? ''; 108 } 109 } 110 111 /** 112 * Is the plugin ready to be used?. 113 * 114 * @return bool|string Returns true on success, a string detailing the error otherwise 115 */ 116 public function is_ready() { 117 118 if (!$this->useserver) { 119 return $this->is_webserver_ready(); 120 } else { 121 return $this->is_python_server_ready(); 122 } 123 } 124 125 /** 126 * Checks if the python package is available in the web server executing this script. 127 * 128 * @return bool|string Returns true on success, a string detailing the error otherwise 129 */ 130 protected function is_webserver_ready() { 131 if (empty($this->pathtopython)) { 132 $settingurl = new \moodle_url('/admin/settings.php', array('section' => 'systempaths')); 133 return get_string('pythonpathnotdefined', 'mlbackend_python', $settingurl->out()); 134 } 135 136 // Check the installed pip package version. 137 $cmd = "{$this->pathtopython} -m moodlemlbackend.version"; 138 139 $output = null; 140 $exitcode = null; 141 // Execute it sending the standard error to $output. 142 $result = exec($cmd . ' 2>&1', $output, $exitcode); 143 144 if ($exitcode != 0) { 145 return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd); 146 } 147 148 $vercheck = self::check_pip_package_version($result); 149 return $this->version_check_return($result, $vercheck); 150 } 151 152 /** 153 * Checks if the server can be accessed. 154 * 155 * @return bool|string True or an error string. 156 */ 157 protected function is_python_server_ready() { 158 159 if (empty($this->host) || empty($this->port) || empty($this->username) || empty($this->password)) { 160 return get_string('errornoconfigdata', 'mlbackend_python'); 161 } 162 163 // Connection is allowed to use 'localhost' and other potentially blocked hosts/ports. 164 $curl = new \curl(['ignoresecurity' => true]); 165 $responsebody = $curl->get($this->get_server_url('version')->out(false)); 166 if ($curl->info['http_code'] !== 200) { 167 return get_string('errorserver', 'mlbackend_python', $this->server_error_str($curl->info['http_code'], $responsebody)); 168 } 169 170 $vercheck = self::check_pip_package_version($responsebody); 171 return $this->version_check_return($responsebody, $vercheck); 172 173 } 174 175 /** 176 * Delete the model version output directory. 177 * 178 * @throws \moodle_exception 179 * @param string $uniqueid 180 * @param string $modelversionoutputdir 181 * @return null 182 */ 183 public function clear_model($uniqueid, $modelversionoutputdir) { 184 if (!$this->useserver) { 185 remove_dir($modelversionoutputdir); 186 } else { 187 // Use the server. 188 189 $url = $this->get_server_url('deletemodel'); 190 list($responsebody, $httpcode) = $this->server_request($url, 'post', ['uniqueid' => $uniqueid]); 191 } 192 } 193 194 /** 195 * Delete the model output directory. 196 * 197 * @throws \moodle_exception 198 * @param string $modeloutputdir 199 * @param string $uniqueid 200 * @return null 201 */ 202 public function delete_output_dir($modeloutputdir, $uniqueid) { 203 if (!$this->useserver) { 204 remove_dir($modeloutputdir); 205 } else { 206 207 $url = $this->get_server_url('deletemodel'); 208 list($responsebody, $httpcode) = $this->server_request($url, 'post', ['uniqueid' => $uniqueid]); 209 } 210 } 211 212 /** 213 * Trains a machine learning algorithm with the provided dataset. 214 * 215 * @param string $uniqueid 216 * @param \stored_file $dataset 217 * @param string $outputdir 218 * @return \stdClass 219 */ 220 public function train_classification($uniqueid, \stored_file $dataset, $outputdir) { 221 222 if (!$this->useserver) { 223 // Use the local file system. 224 225 list($result, $exitcode) = $this->exec_command('training', [$uniqueid, $outputdir, 226 $this->get_file_path($dataset)], 'errornopredictresults'); 227 228 } else { 229 // Use the server. 230 231 $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir), 232 'dataset' => $dataset]; 233 234 $url = $this->get_server_url('training'); 235 list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); 236 } 237 238 if (!$resultobj = json_decode($result)) { 239 throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); 240 } 241 242 if ($resultobj->status != 0) { 243 $resultobj = $this->format_error_info($resultobj); 244 } 245 246 return $resultobj; 247 } 248 249 /** 250 * Classifies the provided dataset samples. 251 * 252 * @param string $uniqueid 253 * @param \stored_file $dataset 254 * @param string $outputdir 255 * @return \stdClass 256 */ 257 public function classify($uniqueid, \stored_file $dataset, $outputdir) { 258 259 if (!$this->useserver) { 260 // Use the local file system. 261 262 list($result, $exitcode) = $this->exec_command('prediction', [$uniqueid, $outputdir, 263 $this->get_file_path($dataset)], 'errornopredictresults'); 264 265 } else { 266 // Use the server. 267 268 $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir), 269 'dataset' => $dataset]; 270 271 $url = $this->get_server_url('prediction'); 272 list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); 273 } 274 275 if (!$resultobj = json_decode($result)) { 276 throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); 277 } 278 279 if ($resultobj->status != 0) { 280 $resultobj = $this->format_error_info($resultobj); 281 } 282 283 return $resultobj; 284 } 285 286 /** 287 * Evaluates this processor classification model using the provided supervised learning dataset. 288 * 289 * @param string $uniqueid 290 * @param float $maxdeviation 291 * @param int $niterations 292 * @param \stored_file $dataset 293 * @param string $outputdir 294 * @param string $trainedmodeldir 295 * @return \stdClass 296 */ 297 public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, 298 $outputdir, $trainedmodeldir) { 299 global $CFG; 300 301 if (!$this->useserver) { 302 // Use the local file system. 303 304 $datasetpath = $this->get_file_path($dataset); 305 306 $params = [$uniqueid, $outputdir, $datasetpath, \core_analytics\model::MIN_SCORE, 307 $maxdeviation, $niterations]; 308 309 if ($trainedmodeldir) { 310 $params[] = $trainedmodeldir; 311 } 312 313 list($result, $exitcode) = $this->exec_command('evaluation', $params, 'errornopredictresults'); 314 if (!$resultobj = json_decode($result)) { 315 throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); 316 } 317 318 } else { 319 // Use the server. 320 321 $requestparams = ['uniqueid' => $uniqueid, 'minscore' => \core_analytics\model::MIN_SCORE, 322 'maxdeviation' => $maxdeviation, 'niterations' => $niterations, 323 'dirhash' => $this->hash_dir($outputdir), 'dataset' => $dataset]; 324 325 if ($trainedmodeldir) { 326 $requestparams['trainedmodeldirhash'] = $this->hash_dir($trainedmodeldir); 327 } 328 329 $url = $this->get_server_url('evaluation'); 330 list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); 331 332 if (!$resultobj = json_decode($result)) { 333 throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); 334 } 335 336 // We need an extra request to get the resources generated during the evaluation process. 337 338 // Directory to temporarly store the evaluation log zip returned by the server. 339 $evaluationtmpdir = make_request_directory(); 340 $evaluationzippath = $evaluationtmpdir . DIRECTORY_SEPARATOR . 'evaluationlog.zip'; 341 342 $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir), 343 'runid' => $resultobj->runid]; 344 345 $url = $this->get_server_url('evaluationlog'); 346 list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams, 347 ['filepath' => $evaluationzippath]); 348 349 $rundir = $outputdir . DIRECTORY_SEPARATOR . 'logs' . DIRECTORY_SEPARATOR . $resultobj->runid; 350 if (!mkdir($rundir, $CFG->directorypermissions, true)) { 351 throw new \moodle_exception('errorexportmodelresult', 'analytics'); 352 } 353 354 $zip = new \zip_packer(); 355 $success = $zip->extract_to_pathname($evaluationzippath, $rundir, null, null, true); 356 if (!$success) { 357 $a = 'The evaluation files can not be exported to ' . $rundir; 358 throw new \moodle_exception('errorpredictionsprocessor', 'analytics', '', $a); 359 } 360 361 $resultobj->dir = $rundir; 362 } 363 364 $resultobj = $this->add_extra_result_info($resultobj); 365 366 return $resultobj; 367 } 368 369 /** 370 * Exports the machine learning model. 371 * 372 * @throws \moodle_exception 373 * @param string $uniqueid The model unique id 374 * @param string $modeldir The directory that contains the trained model. 375 * @return string The path to the directory that contains the exported model. 376 */ 377 public function export(string $uniqueid, string $modeldir) : string { 378 379 $exporttmpdir = make_request_directory(); 380 381 if (!$this->useserver) { 382 // Use the local file system. 383 384 // We include an exporttmpdir as we want to be sure that the file is not deleted after the 385 // python process finishes. 386 list($exportdir, $exitcode) = $this->exec_command('export', [$uniqueid, $modeldir, $exporttmpdir], 387 'errorexportmodelresult'); 388 389 if ($exitcode != 0) { 390 throw new \moodle_exception('errorexportmodelresult', 'analytics'); 391 } 392 393 } else { 394 // Use the server. 395 396 $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir)]; 397 398 $exportzippath = $exporttmpdir . DIRECTORY_SEPARATOR . 'export.zip'; 399 $url = $this->get_server_url('export'); 400 list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams, 401 ['filepath' => $exportzippath]); 402 403 $exportdir = make_request_directory(); 404 $zip = new \zip_packer(); 405 $success = $zip->extract_to_pathname($exportzippath, $exportdir, null, null, true); 406 if (!$success) { 407 throw new \moodle_exception('errorexportmodelresult', 'analytics'); 408 } 409 } 410 411 return $exportdir; 412 } 413 414 /** 415 * Imports the provided machine learning model. 416 * 417 * @param string $uniqueid The model unique id 418 * @param string $modeldir The directory that will contain the trained model. 419 * @param string $importdir The directory that contains the files to import. 420 * @return bool Success 421 */ 422 public function import(string $uniqueid, string $modeldir, string $importdir) : bool { 423 424 if (!$this->useserver) { 425 // Use the local file system. 426 427 list($result, $exitcode) = $this->exec_command('import', [$uniqueid, $modeldir, $importdir], 428 'errorimportmodelresult'); 429 430 if ($exitcode != 0) { 431 throw new \moodle_exception('errorimportmodelresult', 'analytics'); 432 } 433 434 } else { 435 // Use the server. 436 437 // Zip the $importdir to send a single file. 438 $importzipfile = $this->zip_dir($importdir); 439 if (!$importzipfile) { 440 // There was an error zipping the directory. 441 throw new \moodle_exception('errorimportmodelresult', 'analytics'); 442 } 443 444 $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir), 445 'importzip' => curl_file_create($importzipfile, null, 'import.zip')]; 446 $url = $this->get_server_url('import'); 447 list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); 448 } 449 450 return (bool)$result; 451 } 452 453 /** 454 * Train this processor regression model using the provided supervised learning dataset. 455 * 456 * @throws new \coding_exception 457 * @param string $uniqueid 458 * @param \stored_file $dataset 459 * @param string $outputdir 460 * @return \stdClass 461 */ 462 public function train_regression($uniqueid, \stored_file $dataset, $outputdir) { 463 throw new \coding_exception('This predictor does not support regression yet.'); 464 } 465 466 /** 467 * Estimates linear values for the provided dataset samples. 468 * 469 * @throws new \coding_exception 470 * @param string $uniqueid 471 * @param \stored_file $dataset 472 * @param mixed $outputdir 473 * @return void 474 */ 475 public function estimate($uniqueid, \stored_file $dataset, $outputdir) { 476 throw new \coding_exception('This predictor does not support regression yet.'); 477 } 478 479 /** 480 * Evaluates this processor regression model using the provided supervised learning dataset. 481 * 482 * @throws new \coding_exception 483 * @param string $uniqueid 484 * @param float $maxdeviation 485 * @param int $niterations 486 * @param \stored_file $dataset 487 * @param string $outputdir 488 * @param string $trainedmodeldir 489 * @return \stdClass 490 */ 491 public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, 492 $outputdir, $trainedmodeldir) { 493 throw new \coding_exception('This predictor does not support regression yet.'); 494 } 495 496 /** 497 * Returns the path to the dataset file. 498 * 499 * @param \stored_file $file 500 * @return string 501 */ 502 protected function get_file_path(\stored_file $file) { 503 // From moodle filesystem to the local file system. 504 // This is not ideal, but there is no read access to moodle filesystem files. 505 return $file->copy_content_to_temp('core_analytics'); 506 } 507 508 /** 509 * Check that the given package version can be used and return the error status. 510 * 511 * When evaluating the version, we assume the sematic versioning scheme as described at 512 * https://semver.org/. 513 * 514 * @param string $actual The actual Python package version 515 * @param string $required The required version of the package 516 * @return int -1 = actual version is too low, 1 = actual version too high, 0 = actual version is ok 517 */ 518 public static function check_pip_package_version($actual, $required = self::REQUIRED_PIP_PACKAGE_VERSION) { 519 520 if (empty($actual)) { 521 return -1; 522 } 523 524 if (version_compare($actual, $required, '<')) { 525 return -1; 526 } 527 528 $parts = explode('.', $required); 529 $requiredapiver = reset($parts); 530 531 $parts = explode('.', $actual); 532 $actualapiver = reset($parts); 533 534 if ($requiredapiver > 0 || $actualapiver > 1) { 535 if (version_compare($actual, $requiredapiver + 1, '>=')) { 536 return 1; 537 } 538 } 539 540 return 0; 541 } 542 543 /** 544 * Executes the specified module. 545 * 546 * @param string $modulename 547 * @param array $params 548 * @param string $errorlangstr 549 * @return array [0] is the result body and [1] the exit code. 550 */ 551 protected function exec_command(string $modulename, array $params, string $errorlangstr) { 552 553 $cmd = $this->pathtopython . ' -m moodlemlbackend.' . $modulename . ' '; 554 foreach ($params as $param) { 555 $cmd .= escapeshellarg($param) . ' '; 556 } 557 558 if (!PHPUNIT_TEST && CLI_SCRIPT) { 559 debugging($cmd, DEBUG_DEVELOPER); 560 } 561 562 $output = null; 563 $exitcode = null; 564 $result = exec($cmd, $output, $exitcode); 565 566 if (!$result) { 567 throw new \moodle_exception($errorlangstr, 'analytics'); 568 } 569 570 return [$result, $exitcode]; 571 } 572 573 /** 574 * Formats the errors and info in a single info string. 575 * 576 * @param \stdClass $resultobj 577 * @return \stdClass 578 */ 579 private function format_error_info(\stdClass $resultobj) { 580 if (!empty($resultobj->errors)) { 581 $errors = $resultobj->errors; 582 if (is_array($errors)) { 583 $errors = implode(', ', $errors); 584 } 585 } else if (!empty($resultobj->info)) { 586 // Show info if no errors are returned. 587 $errors = $resultobj->info; 588 if (is_array($errors)) { 589 $errors = implode(', ', $errors); 590 } 591 } 592 $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors)); 593 594 return $resultobj; 595 } 596 597 /** 598 * Returns the url to the python ML server. 599 * 600 * @param string|null $path 601 * @return \moodle_url 602 */ 603 private function get_server_url(?string $path = null) { 604 $protocol = !empty($this->secure) ? 'https' : 'http'; 605 $url = $protocol . '://' . rtrim($this->host, '/'); 606 if (!empty($this->port)) { 607 $url .= ':' . $this->port; 608 } 609 610 if ($path) { 611 $url .= '/' . $path; 612 } 613 614 return new \moodle_url($url); 615 } 616 617 /** 618 * Sends a request to the python ML server. 619 * 620 * @param \moodle_url $url The requested url in the python ML server 621 * @param string $method The curl method to use 622 * @param array $requestparams Curl request params 623 * @param array|null $options Curl request options 624 * @return array [0] for the response body and [1] for the http code 625 */ 626 protected function server_request($url, string $method, array $requestparams, ?array $options = null) { 627 628 if ($method !== 'post' && $method !== 'get' && $method !== 'download_one') { 629 throw new \coding_exception('Incorrect request method provided. Only "get", "post" and "download_one" 630 actions are available.'); 631 } 632 633 // Connection is allowed to use 'localhost' and other potentially blocked hosts/ports. 634 $curl = new \curl(['ignoresecurity' => true]); 635 636 $authorization = $this->username . ':' . $this->password; 637 $curl->setHeader('Authorization: Basic ' . base64_encode($authorization)); 638 639 $responsebody = $curl->{$method}($url, $requestparams, $options); 640 641 if ($curl->info['http_code'] !== 200) { 642 throw new \moodle_exception('errorserver', 'mlbackend_python', '', 643 $this->server_error_str($curl->info['http_code'], $responsebody)); 644 } 645 646 return [$responsebody, $curl->info['http_code']]; 647 } 648 649 /** 650 * Adds extra information to results info. 651 * 652 * @param \stdClass $resultobj 653 * @return \stdClass 654 */ 655 protected function add_extra_result_info(\stdClass $resultobj): \stdClass { 656 657 if (!empty($resultobj->dir)) { 658 $dir = $resultobj->dir . DIRECTORY_SEPARATOR . 'tensor'; 659 $resultobj->info[] = get_string('tensorboardinfo', 'mlbackend_python', $dir); 660 } 661 return $resultobj; 662 } 663 664 /** 665 * Returns the proper return value for the version checking. 666 * 667 * @param string $actual Actual moodlemlbackend version 668 * @param int $vercheck Version checking result 669 * @return true|string Returns true on success, a string detailing the error otherwise 670 */ 671 private function version_check_return($actual, $vercheck) { 672 673 if ($vercheck === 0) { 674 return true; 675 } 676 677 if ($actual) { 678 $a = [ 679 'installed' => $actual, 680 'required' => self::REQUIRED_PIP_PACKAGE_VERSION, 681 ]; 682 683 if ($vercheck < 0) { 684 return get_string('packageinstalledshouldbe', 'mlbackend_python', $a); 685 686 } else if ($vercheck > 0) { 687 return get_string('packageinstalledtoohigh', 'mlbackend_python', $a); 688 } 689 } 690 691 if (!$this->useserver) { 692 $cmd = "{$this->pathtopython} -m moodlemlbackend.version"; 693 } else { 694 // We can't not know which is the python bin in the python ML server, the most likely 695 // value is 'python'. 696 $cmd = "python -m moodlemlbackend.version"; 697 } 698 return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd); 699 } 700 701 /** 702 * Hashes the provided dir as a string. 703 * 704 * @param string $dir Directory path 705 * @return string Hash 706 */ 707 private function hash_dir(string $dir) { 708 return md5($dir); 709 } 710 711 /** 712 * Zips the provided directory. 713 * 714 * @param string $dir Directory path 715 * @return string The zip filename 716 */ 717 private function zip_dir(string $dir) { 718 719 $ziptmpdir = make_request_directory(); 720 $ziptmpfile = $ziptmpdir . DIRECTORY_SEPARATOR . 'mlbackend.zip'; 721 722 $files = get_directory_list($dir); 723 $zipfiles = []; 724 foreach ($files as $file) { 725 $fullpath = $dir . DIRECTORY_SEPARATOR . $file; 726 // Use the relative path to the file as the path in the zip. 727 $zipfiles[$file] = $fullpath; 728 } 729 730 $zip = new \zip_packer(); 731 if (!$zip->archive_to_pathname($zipfiles, $ziptmpfile)) { 732 return false; 733 } 734 735 return $ziptmpfile; 736 } 737 738 /** 739 * Error string for httpcode !== 200 740 * 741 * @param int $httpstatuscode The HTTP status code 742 * @param string $responsebody The body of the response 743 */ 744 private function server_error_str(int $httpstatuscode, string $responsebody): string { 745 return 'HTTP status code ' . $httpstatuscode . ': ' . $responsebody; 746 } 747 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body