Differences Between: [Versions 310 and 311] [Versions 310 and 400] [Versions 310 and 401] [Versions 310 and 402] [Versions 310 and 403] [Versions 39 and 310]
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 = '2.6.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 280 if ($resultobj->status != 0) { 281 $resultobj = $this->format_error_info($resultobj); 282 } 283 284 return $resultobj; 285 } 286 287 /** 288 * Evaluates this processor classification model using the provided supervised learning dataset. 289 * 290 * @param string $uniqueid 291 * @param float $maxdeviation 292 * @param int $niterations 293 * @param \stored_file $dataset 294 * @param string $outputdir 295 * @param string $trainedmodeldir 296 * @return \stdClass 297 */ 298 public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, 299 $outputdir, $trainedmodeldir) { 300 global $CFG; 301 302 if (!$this->useserver) { 303 // Use the local file system. 304 305 $datasetpath = $this->get_file_path($dataset); 306 307 $params = [$uniqueid, $outputdir, $datasetpath, \core_analytics\model::MIN_SCORE, 308 $maxdeviation, $niterations]; 309 310 if ($trainedmodeldir) { 311 $params[] = $trainedmodeldir; 312 } 313 314 list($result, $exitcode) = $this->exec_command('evaluation', $params, 'errornopredictresults'); 315 if (!$resultobj = json_decode($result)) { 316 throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); 317 } 318 319 } else { 320 // Use the server. 321 322 $requestparams = ['uniqueid' => $uniqueid, 'minscore' => \core_analytics\model::MIN_SCORE, 323 'maxdeviation' => $maxdeviation, 'niterations' => $niterations, 324 'dirhash' => $this->hash_dir($outputdir), 'dataset' => $dataset]; 325 326 if ($trainedmodeldir) { 327 $requestparams['trainedmodeldirhash'] = $this->hash_dir($trainedmodeldir); 328 } 329 330 $url = $this->get_server_url('evaluation'); 331 list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); 332 333 if (!$resultobj = json_decode($result)) { 334 throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg()); 335 } 336 337 // We need an extra request to get the resources generated during the evaluation process. 338 339 // Directory to temporarly store the evaluation log zip returned by the server. 340 $evaluationtmpdir = make_request_directory('mlbackend_python_evaluationlog'); 341 $evaluationzippath = $evaluationtmpdir . DIRECTORY_SEPARATOR . 'evaluationlog.zip'; 342 343 $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir), 344 'runid' => $resultobj->runid]; 345 346 $url = $this->get_server_url('evaluationlog'); 347 list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams, 348 ['filepath' => $evaluationzippath]); 349 350 $rundir = $outputdir . DIRECTORY_SEPARATOR . 'logs' . DIRECTORY_SEPARATOR . $resultobj->runid; 351 if (!mkdir($rundir, $CFG->directorypermissions, true)) { 352 throw new \moodle_exception('errorexportmodelresult', 'analytics'); 353 } 354 355 $zip = new \zip_packer(); 356 $success = $zip->extract_to_pathname($evaluationzippath, $rundir, null, null, true); 357 if (!$success) { 358 $a = 'The evaluation files can not be exported to ' . $rundir; 359 throw new \moodle_exception('errorpredictionsprocessor', 'analytics', '', $a); 360 } 361 362 $resultobj->dir = $rundir; 363 } 364 365 $resultobj = $this->add_extra_result_info($resultobj); 366 367 return $resultobj; 368 } 369 370 /** 371 * Exports the machine learning model. 372 * 373 * @throws \moodle_exception 374 * @param string $uniqueid The model unique id 375 * @param string $modeldir The directory that contains the trained model. 376 * @return string The path to the directory that contains the exported model. 377 */ 378 public function export(string $uniqueid, string $modeldir) : string { 379 380 $exporttmpdir = make_request_directory('mlbackend_python_export'); 381 382 if (!$this->useserver) { 383 // Use the local file system. 384 385 // We include an exporttmpdir as we want to be sure that the file is not deleted after the 386 // python process finishes. 387 list($exportdir, $exitcode) = $this->exec_command('export', [$uniqueid, $modeldir, $exporttmpdir], 388 'errorexportmodelresult'); 389 390 if ($exitcode != 0) { 391 throw new \moodle_exception('errorexportmodelresult', 'analytics'); 392 } 393 394 } else { 395 // Use the server. 396 397 $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir)]; 398 399 $exportzippath = $exporttmpdir . DIRECTORY_SEPARATOR . 'export.zip'; 400 $url = $this->get_server_url('export'); 401 list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams, 402 ['filepath' => $exportzippath]); 403 404 $exportdir = make_request_directory(); 405 $zip = new \zip_packer(); 406 $success = $zip->extract_to_pathname($exportzippath, $exportdir, null, null, true); 407 if (!$success) { 408 throw new \moodle_exception('errorexportmodelresult', 'analytics'); 409 } 410 } 411 412 return $exportdir; 413 } 414 415 /** 416 * Imports the provided machine learning model. 417 * 418 * @param string $uniqueid The model unique id 419 * @param string $modeldir The directory that will contain the trained model. 420 * @param string $importdir The directory that contains the files to import. 421 * @return bool Success 422 */ 423 public function import(string $uniqueid, string $modeldir, string $importdir) : bool { 424 425 if (!$this->useserver) { 426 // Use the local file system. 427 428 list($result, $exitcode) = $this->exec_command('import', [$uniqueid, $modeldir, $importdir], 429 'errorimportmodelresult'); 430 431 if ($exitcode != 0) { 432 throw new \moodle_exception('errorimportmodelresult', 'analytics'); 433 } 434 435 } else { 436 // Use the server. 437 438 // Zip the $importdir to send a single file. 439 $importzipfile = $this->zip_dir($importdir); 440 if (!$importzipfile) { 441 // There was an error zipping the directory. 442 throw new \moodle_exception('errorimportmodelresult', 'analytics'); 443 } 444 445 $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir), 446 'importzip' => curl_file_create($importzipfile, null, 'import.zip')]; 447 $url = $this->get_server_url('import'); 448 list($result, $httpcode) = $this->server_request($url, 'post', $requestparams); 449 } 450 451 return (bool)$result; 452 } 453 454 /** 455 * Train this processor regression model using the provided supervised learning dataset. 456 * 457 * @throws new \coding_exception 458 * @param string $uniqueid 459 * @param \stored_file $dataset 460 * @param string $outputdir 461 * @return \stdClass 462 */ 463 public function train_regression($uniqueid, \stored_file $dataset, $outputdir) { 464 throw new \coding_exception('This predictor does not support regression yet.'); 465 } 466 467 /** 468 * Estimates linear values for the provided dataset samples. 469 * 470 * @throws new \coding_exception 471 * @param string $uniqueid 472 * @param \stored_file $dataset 473 * @param mixed $outputdir 474 * @return void 475 */ 476 public function estimate($uniqueid, \stored_file $dataset, $outputdir) { 477 throw new \coding_exception('This predictor does not support regression yet.'); 478 } 479 480 /** 481 * Evaluates this processor regression model using the provided supervised learning dataset. 482 * 483 * @throws new \coding_exception 484 * @param string $uniqueid 485 * @param float $maxdeviation 486 * @param int $niterations 487 * @param \stored_file $dataset 488 * @param string $outputdir 489 * @param string $trainedmodeldir 490 * @return \stdClass 491 */ 492 public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset, 493 $outputdir, $trainedmodeldir) { 494 throw new \coding_exception('This predictor does not support regression yet.'); 495 } 496 497 /** 498 * Returns the path to the dataset file. 499 * 500 * @param \stored_file $file 501 * @return string 502 */ 503 protected function get_file_path(\stored_file $file) { 504 // From moodle filesystem to the local file system. 505 // This is not ideal, but there is no read access to moodle filesystem files. 506 return $file->copy_content_to_temp('core_analytics'); 507 } 508 509 /** 510 * Check that the given package version can be used and return the error status. 511 * 512 * When evaluating the version, we assume the sematic versioning scheme as described at 513 * https://semver.org/. 514 * 515 * @param string $actual The actual Python package version 516 * @param string $required The required version of the package 517 * @return int -1 = actual version is too low, 1 = actual version too high, 0 = actual version is ok 518 */ 519 public static function check_pip_package_version($actual, $required = self::REQUIRED_PIP_PACKAGE_VERSION) { 520 521 if (empty($actual)) { 522 return -1; 523 } 524 525 if (version_compare($actual, $required, '<')) { 526 return -1; 527 } 528 529 $parts = explode('.', $required); 530 $requiredapiver = reset($parts); 531 532 $parts = explode('.', $actual); 533 $actualapiver = reset($parts); 534 535 if ($requiredapiver > 0 || $actualapiver > 1) { 536 if (version_compare($actual, $requiredapiver + 1, '>=')) { 537 return 1; 538 } 539 } 540 541 return 0; 542 } 543 544 /** 545 * Executes the specified module. 546 * 547 * @param string $modulename 548 * @param array $params 549 * @param string $errorlangstr 550 * @return array [0] is the result body and [1] the exit code. 551 */ 552 protected function exec_command(string $modulename, array $params, string $errorlangstr) { 553 554 $cmd = $this->pathtopython . ' -m moodlemlbackend.' . $modulename . ' '; 555 foreach ($params as $param) { 556 $cmd .= escapeshellarg($param) . ' '; 557 } 558 559 if (!PHPUNIT_TEST && CLI_SCRIPT) { 560 debugging($cmd, DEBUG_DEVELOPER); 561 } 562 563 $output = null; 564 $exitcode = null; 565 $result = exec($cmd, $output, $exitcode); 566 567 if (!$result) { 568 throw new \moodle_exception($errorlangstr, 'analytics'); 569 } 570 571 return [$result, $exitcode]; 572 } 573 574 /** 575 * Formats the errors and info in a single info string. 576 * 577 * @param \stdClass $resultobj 578 * @return \stdClass 579 */ 580 private function format_error_info(\stdClass $resultobj) { 581 if (!empty($resultobj->errors)) { 582 $errors = $resultobj->errors; 583 if (is_array($errors)) { 584 $errors = implode(', ', $errors); 585 } 586 } else if (!empty($resultobj->info)) { 587 // Show info if no errors are returned. 588 $errors = $resultobj->info; 589 if (is_array($errors)) { 590 $errors = implode(', ', $errors); 591 } 592 } 593 $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors)); 594 595 return $resultobj; 596 } 597 598 /** 599 * Returns the url to the python ML server. 600 * 601 * @param string|null $path 602 * @return \moodle_url 603 */ 604 private function get_server_url(?string $path = null) { 605 $protocol = !empty($this->secure) ? 'https' : 'http'; 606 $url = $protocol . '://' . rtrim($this->host, '/'); 607 if (!empty($this->port)) { 608 $url .= ':' . $this->port; 609 } 610 611 if ($path) { 612 $url .= '/' . $path; 613 } 614 615 return new \moodle_url($url); 616 } 617 618 /** 619 * Sends a request to the python ML server. 620 * 621 * @param \moodle_url $url The requested url in the python ML server 622 * @param string $method The curl method to use 623 * @param array $requestparams Curl request params 624 * @param array|null $options Curl request options 625 * @return array [0] for the response body and [1] for the http code 626 */ 627 protected function server_request($url, string $method, array $requestparams, ?array $options = null) { 628 629 if ($method !== 'post' && $method !== 'get' && $method !== 'download_one') { 630 throw new \coding_exception('Incorrect request method provided. Only "get", "post" and "download_one" 631 actions are available.'); 632 } 633 634 // Connection is allowed to use 'localhost' and other potentially blocked hosts/ports. 635 $curl = new \curl(['ignoresecurity' => true]); 636 637 $authorization = $this->username . ':' . $this->password; 638 $curl->setHeader('Authorization: Basic ' . base64_encode($authorization)); 639 640 $responsebody = $curl->{$method}($url, $requestparams, $options); 641 642 if ($curl->info['http_code'] !== 200) { 643 throw new \moodle_exception('errorserver', 'mlbackend_python', '', 644 $this->server_error_str($curl->info['http_code'], $responsebody)); 645 } 646 647 return [$responsebody, $curl->info['http_code']]; 648 } 649 650 /** 651 * Adds extra information to results info. 652 * 653 * @param \stdClass $resultobj 654 * @return \stdClass 655 */ 656 protected function add_extra_result_info(\stdClass $resultobj): \stdClass { 657 658 if (!empty($resultobj->dir)) { 659 $dir = $resultobj->dir . DIRECTORY_SEPARATOR . 'tensor'; 660 $resultobj->info[] = get_string('tensorboardinfo', 'mlbackend_python', $dir); 661 } 662 return $resultobj; 663 } 664 665 /** 666 * Returns the proper return value for the version checking. 667 * 668 * @param string $actual Actual moodlemlbackend version 669 * @param int $vercheck Version checking result 670 * @return true|string Returns true on success, a string detailing the error otherwise 671 */ 672 private function version_check_return($actual, $vercheck) { 673 674 if ($vercheck === 0) { 675 return true; 676 } 677 678 if ($actual) { 679 $a = [ 680 'installed' => $actual, 681 'required' => self::REQUIRED_PIP_PACKAGE_VERSION, 682 ]; 683 684 if ($vercheck < 0) { 685 return get_string('packageinstalledshouldbe', 'mlbackend_python', $a); 686 687 } else if ($vercheck > 0) { 688 return get_string('packageinstalledtoohigh', 'mlbackend_python', $a); 689 } 690 } 691 692 if (!$this->useserver) { 693 $cmd = "{$this->pathtopython} -m moodlemlbackend.version"; 694 } else { 695 // We can't not know which is the python bin in the python ML server, the most likely 696 // value is 'python'. 697 $cmd = "python -m moodlemlbackend.version"; 698 } 699 return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd); 700 } 701 702 /** 703 * Hashes the provided dir as a string. 704 * 705 * @param string $dir Directory path 706 * @return string Hash 707 */ 708 private function hash_dir(string $dir) { 709 return md5($dir); 710 } 711 712 /** 713 * Zips the provided directory. 714 * 715 * @param string $dir Directory path 716 * @return string The zip filename 717 */ 718 private function zip_dir(string $dir) { 719 720 $ziptmpdir = make_request_directory('mlbackend_python'); 721 $ziptmpfile = $ziptmpdir . DIRECTORY_SEPARATOR . 'mlbackend.zip'; 722 723 $files = get_directory_list($dir); 724 $zipfiles = []; 725 foreach ($files as $file) { 726 $fullpath = $dir . DIRECTORY_SEPARATOR . $file; 727 // Use the relative path to the file as the path in the zip. 728 $zipfiles[$file] = $fullpath; 729 } 730 731 $zip = new \zip_packer(); 732 if (!$zip->archive_to_pathname($zipfiles, $ziptmpfile)) { 733 return false; 734 } 735 736 return $ziptmpfile; 737 } 738 739 /** 740 * Error string for httpcode !== 200 741 * 742 * @param int $httpstatuscode The HTTP status code 743 * @param string $responsebody The body of the response 744 */ 745 private function server_error_str(int $httpstatuscode, string $responsebody): string { 746 return 'HTTP status code ' . $httpstatuscode . ': ' . $responsebody; 747 } 748 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body