diff --git a/admin/tool/analytics/classes/output/form/import_model.php b/admin/tool/analytics/classes/output/form/import_model.php index 8c17e395797..1b4c3756bb1 100644 --- a/admin/tool/analytics/classes/output/form/import_model.php +++ b/admin/tool/analytics/classes/output/form/import_model.php @@ -45,7 +45,7 @@ class import_model extends \moodleform { $mform->addElement('header', 'settingsheader', get_string('importmodel', 'tool_analytics')); - $mform->addElement('filepicker', 'modelfile', get_string('file'), null, ['accepted_types' => '.json']); + $mform->addElement('filepicker', 'modelfile', get_string('file'), null, ['accepted_types' => '.zip']); $mform->addRule('modelfile', null, 'required'); $mform->addElement('advcheckbox', 'ignoreversionmismatches', get_string('ignoreversionmismatches', 'tool_analytics'), diff --git a/admin/tool/analytics/classes/output/helper.php b/admin/tool/analytics/classes/output/helper.php index 59b33d838e8..6543022315d 100644 --- a/admin/tool/analytics/classes/output/helper.php +++ b/admin/tool/analytics/classes/output/helper.php @@ -64,7 +64,7 @@ class helper { * * @param string $title * @param \moodle_url $url - * @param \context|false $context Defaults to context_system + * @param \context|null $context Defaults to context_system * @return null */ public static function set_navbar(string $title, \moodle_url $url, ?\context $context = null) { diff --git a/admin/tool/analytics/createmodel.php b/admin/tool/analytics/createmodel.php index 8ee0f4d9d37..bf1701c201e 100644 --- a/admin/tool/analytics/createmodel.php +++ b/admin/tool/analytics/createmodel.php @@ -54,7 +54,10 @@ if ($mform->is_cancelled()) { // Converting option names to class names. $targetclass = \tool_analytics\output\helper::option_to_class($data->target); - $target = \core_analytics\manager::get_target($targetclass); + if (empty($targets[$targetclass])) { + throw new \moodle_exception('errorinvalidtarget', 'analytics', '', $targetclass); + } + $target = $targets[$targetclass]; $indicators = array(); foreach ($data->indicators as $indicator) { @@ -88,4 +91,4 @@ if ($mform->is_cancelled()) { echo $OUTPUT->header(); $mform->display(); -echo $OUTPUT->footer(); \ No newline at end of file +echo $OUTPUT->footer(); diff --git a/admin/tool/analytics/importmodel.php b/admin/tool/analytics/importmodel.php index 250b14f4ad5..eedeaa0531a 100644 --- a/admin/tool/analytics/importmodel.php +++ b/admin/tool/analytics/importmodel.php @@ -40,19 +40,15 @@ if ($form->is_cancelled()) { $modelconfig = new \core_analytics\model_config(); - $json = $form->get_file_content('modelfile'); + $zipfilepath = $form->save_temp_file('modelfile'); - if ($error = $modelconfig->check_json_data($json)) { - // The provided file is not ok. - redirect($url, $error, 0, \core\output\notification::NOTIFY_ERROR); - } + list ($modeldata, $unused) = $modelconfig->extract_import_contents($zipfilepath); - $modeldata = json_decode($json); if ($error = $modelconfig->check_dependencies($modeldata, $data->ignoreversionmismatches)) { // The file is not available until the form is validated so we need an alternative method to show errors. redirect($url, $error, 0, \core\output\notification::NOTIFY_ERROR); } - $model = \core_analytics\model::create_from_import($modeldata, true); + \core_analytics\model::import_model($zipfilepath); redirect($returnurl, get_string('importedsuccessfully', 'tool_analytics'), 0, \core\output\notification::NOTIFY_SUCCESS); diff --git a/admin/tool/analytics/lang/en/tool_analytics.php b/admin/tool/analytics/lang/en/tool_analytics.php index 267f96a32df..580d7b8b18c 100644 --- a/admin/tool/analytics/lang/en/tool_analytics.php +++ b/admin/tool/analytics/lang/en/tool_analytics.php @@ -37,21 +37,15 @@ $string['clievaluationandpredictions'] = 'A scheduled task iterates through enab $string['clievaluationandpredictionsnoadmin'] = 'A scheduled task iterates through enabled models and gets predictions. Models evaluation via the web interface is disabled. It may be enabled by a site administrator.'; $string['createmodel'] = 'Create model'; $string['delete'] = 'Delete'; -$string['deletemodelconfirmation'] = 'Are you sure you want to delete "{$a}"?'; +$string['deletemodelconfirmation'] = 'Are you sure you want to delete "{$a}"? These changes can not be reverted.'; $string['disabled'] = 'Disabled'; $string['editmodel'] = 'Edit "{$a}" model'; $string['edittrainedwarning'] = 'This model has already been trained. Note that changing its indicators or its time-splitting method will delete its previous predictions and start generating new predictions.'; $string['enabled'] = 'Enabled'; $string['errorcantenablenotimesplitting'] = 'You need to select a time-splitting method before enabling the model'; -$string['errorimport'] = 'Error importing the provided json file.'; -$string['errorimportmissingcomponents'] = 'The provided model requires the following plugins to be installed: {$a}. Note that the versions do not necessarily need to match with the versions installed in your system. To install the same or a newer version of the plugin should be enough in most cases.'; -$string['errorimportversionmismatches'] = 'The version of the following components differ from the version installed in this site: {$a}. You can use "Ignore version mismatches" option to ignore these differences.'; -$string['errorimportmissingclasses'] = 'The following analytics components are not available in this site: {$a->missingclasses}. '; $string['errornoenabledandtrainedmodels'] = 'There are no enabled and trained models to predict.'; $string['errornoenabledmodels'] = 'There are no enabled models to train.'; $string['errornoexport'] = 'Only trained models can be exported'; -$string['errornoexportconfig'] = 'There was a problem exporting the model configuration.'; -$string['errornoexportconfigrequirements'] = 'Only non static models with timeplitting methods can be exported.'; $string['errornostaticedit'] = 'Models based on assumptions cannot be edited.'; $string['errornostaticevaluated'] = 'Models based on assumptions cannot be evaluated. They are always 100% correct according to how they were defined.'; $string['errornostaticlog'] = 'Models based on assumptions cannot be evaluated because there is no performance log.'; diff --git a/admin/tool/analytics/model.php b/admin/tool/analytics/model.php index f385410e41a..23d84540255 100644 --- a/admin/tool/analytics/model.php +++ b/admin/tool/analytics/model.php @@ -229,10 +229,9 @@ switch ($action) { break; case 'exportmodel': - $downloadfilename = 'model-config.' . $model->get_id() . '.' . microtime() . '.json'; - $filepath = $model->export_config($downloadfilename); - @header("Content-type: text/json; charset=UTF-8"); - send_temp_file($filepath, $downloadfilename); + $zipfilename = 'model-' . $model->get_unique_id() . '-' . microtime(false) . '.zip'; + $zipfilepath = $model->export_model($zipfilename); + send_temp_file($zipfilepath, $zipfilename); break; case 'clear': diff --git a/analytics/classes/model.php b/analytics/classes/model.php index 839b7e2beec..3b6c6e40c53 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -338,12 +338,12 @@ class model { * * @param \core_analytics\local\target\base $target * @param \core_analytics\local\indicator\base[] $indicators - * @param string $timesplittingid The time splitting method id (its fully qualified class name) - * @param string $processor The machine learning backend this model will use. + * @param string|false $timesplittingid The time splitting method id (its fully qualified class name) + * @param string|null $processor The machine learning backend this model will use. * @return \core_analytics\model */ public static function create(\core_analytics\local\target\base $target, array $indicators, - $timesplittingid = false, $processor = false) { + $timesplittingid = false, $processor = null) { global $USER, $DB; \core_analytics\manager::check_can_manage_models(); @@ -386,59 +386,6 @@ class model { return $model; } - /** - * Creates a new model from import configuration. - * - * It is recommended to call \core_analytics\model_config::check_dependencies first so the error message can be retrieved. - * - * @param \stdClass $modeldata Model data. - * @param bool $skipcheckdependencies Useful if you already checked the dependencies. - * @return \core_analytics\model|false False if the provided model data contain errors. - */ - public static function create_from_import(\stdClass $modeldata, ?bool $skipcheckdependencies = false) : ?\core_analytics\model { - - \core_analytics\manager::check_can_manage_models(); - - if (!$skipcheckdependencies) { - $modelconfig = new model_config(); - if ($error = $modelconfig->check_dependencies($modeldata, false)) { - return null; - } - } - - // At this stage we should be 100% sure that the model data is safe and can be imported. - // If the caller explicitly set $skipcheckdependencies to false and there is a problem - // in this process we trigger a coding exception. - if (!$target = \core_analytics\manager::get_target($modeldata->target)) { - throw new \coding_exception('The provided target is not available. Ensure that model_config::check_dependencies - is called before importing the model.'); - } - if (!$timesplitting = \core_analytics\manager::get_time_splitting($modeldata->timesplitting)) { - throw new \coding_exception('The provided time splitting method is not available. Ensure that - model_config::check_dependencies is called before importing the model.'); - } - - // Indicators. - $indicators = []; - foreach ($modeldata->indicators as $indicator) { - if (!$indicator = \core_analytics\manager::get_indicator($indicator)) { - throw new \coding_exception('The provided indicator is not available. Ensure that - model_config::check_dependencies is called before importing the model.'); - } - $indicators[] = $indicator; - } - - if (!empty($modeldata->processor)) { - if (!$processor = \core_analytics\manager::get_predictions_processor($modeldata->processor, false)) { - throw new \coding_exception('The provided machine learning backend is not available. Ensure that - model_config::check_dependencies is called before importing the model.'); - } - } else { - $modeldata->processor = false; - } - return self::create($target, $indicators, $modeldata->timesplitting, $modeldata->processor); - } - /** * Does this model exist? * @@ -1363,7 +1310,7 @@ class model { * @param bool $onlymodelid Preference over $subdirs * @return string */ - protected function get_output_dir($subdirs = array(), $onlymodelid = false) { + public function get_output_dir($subdirs = array(), $onlymodelid = false) { global $CFG; $subdirstr = ''; @@ -1412,7 +1359,7 @@ class model { } /** - * Exports the model data. + * Exports the model data for displaying it in a template. * * @return \stdClass */ @@ -1435,19 +1382,34 @@ class model { } /** - * Exports the model data as a JSON file. + * Exports the model data to a zip file. * - * @param string $downloadfilename Download file name. - * @return string The filepath + * @param string $zipfilename + * @return string Zip file path */ - public function export_config(string $downloadfilename) : string { - global $CFG; + public function export_model(string $zipfilename) : string { \core_analytics\manager::check_can_manage_models(); $modelconfig = new model_config($this); - $modeldata = $modelconfig->export(); - return $modelconfig->export_to_file($modeldata, $downloadfilename); + return $modelconfig->export($zipfilename); + } + + /** + * Imports the provided model. + * + * Note that this method assumes that model_config::check_dependencies has already been called. + * + * @throws \moodle_exception + * @param string $zipfilepath Zip file path + * @return \core_analytics\model + */ + public static function import_model(string $zipfilepath) : \core_analytics\model { + + \core_analytics\manager::check_can_manage_models(); + + $modelconfig = new \core_analytics\model_config(); + return $modelconfig->import($zipfilepath); } /** diff --git a/analytics/classes/model_config.php b/analytics/classes/model_config.php index 162fc15751d..2cb32cc22f4 100644 --- a/analytics/classes/model_config.php +++ b/analytics/classes/model_config.php @@ -40,6 +40,11 @@ class model_config { */ private $model = null; + /** + * The name of the file where config is held. + */ + const CONFIG_FILE_NAME = 'model-config.json'; + /** * Constructor. * @@ -50,100 +55,95 @@ class model_config { } /** - * Exports a model to a temp file using the provided file name. + * Exports a model to a zip using the provided file name. * - * @return \stdClass + * @param string $zipfilename + * @return string */ - public function export() : \stdClass { + public function export(string $zipfilename) : string { if (!$this->model) { throw new \coding_exception('No model object provided.'); } if (!$this->model->can_export_configuration()) { - throw new \moodle_exception('errornoexportconfigrequirements', 'tool_analytics'); + throw new \moodle_exception('errornoexportconfigrequirements', 'analytics'); } - $versions = \core_component::get_all_versions(); + $zip = new \zip_packer(); + $zipfiles = []; - $data = new \stdClass(); + // Model config in JSON. + $modeldata = $this->export_model_data(); - // Target. - $data->target = $this->model->get_target()->get_id(); - $requiredclasses[] = $data->target; + $exporttmpdir = make_request_directory('analyticsexport'); + $jsonfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . 'model-config.json'; + if (!file_put_contents($jsonfilepath, json_encode($modeldata))) { + print_error('errornoexportconfig', 'analytics'); + } + $zipfiles[self::CONFIG_FILE_NAME] = $jsonfilepath; - // Time splitting method. - $data->timesplitting = $this->model->get_time_splitting()->get_id(); - $requiredclasses[] = $data->timesplitting; - - // Model indicators. - $data->indicators = []; - foreach ($this->model->get_indicators() as $indicator) { - $indicatorid = $indicator->get_id(); - $data->indicators[] = $indicatorid; - $requiredclasses[] = $indicatorid; + // ML backend. + if ($this->model->is_trained()) { + $processor = $this->model->get_predictions_processor(true); + $outputdir = $this->model->get_output_dir(array('execution')); + $mlbackenddir = $processor->export($this->model->get_unique_id(), $outputdir); + $mlbackendfiles = get_directory_list($mlbackenddir); + foreach ($mlbackendfiles as $mlbackendfile) { + $fullpath = $mlbackenddir . DIRECTORY_SEPARATOR . $mlbackendfile; + // Place the ML backend files inside a mlbackend/ dir. + $zipfiles['mlbackend/' . $mlbackendfile] = $fullpath; + } } - if ($processor = $this->model->get_model_obj()->predictionsprocessor) { - $data->processor = $processor; - } - // Add information for versioning. - $data->dependencies = []; - foreach ($requiredclasses as $fullclassname) { - $component = $this->get_class_component($fullclassname); - $data->dependencies[$component] = $versions[$component]; - } + $zipfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . $zipfilename; + $zip->archive_to_pathname($zipfiles, $zipfilepath); - return $data; + return $zipfilepath; } /** - * Packages the configuration of a model into a .json file. + * Imports the provided model configuration into a new model. * - * @param \stdClass $data Model config data - * @param string $downloadfilename The file name. - * @return string Path to the file with the model configuration. - */ - public function export_to_file(\stdClass $data, string $downloadfilename) : string { - - $modelconfig = json_encode($data); - - $dir = make_temp_directory('analyticsexport'); - $filepath = $dir . DIRECTORY_SEPARATOR . $downloadfilename; - if (!file_put_contents($filepath, $modelconfig)) { - print_error('errornoexportconfig', 'tool_analytics'); - } - - return $filepath; - } - - /** - * Check the provided json string. + * Note that this method assumes that self::check_dependencies has already been called. * - * @param string $json A json string. - * @return string|null Error string or null if all good. + * @param string $zipfilepath Path to the zip file to import + * @return \core_analytics\model */ - public function check_json_data(string $json) : ?string { + public function import(string $zipfilepath) : \core_analytics\model { - if (!$modeldata = json_decode($json)) { - return get_string('errorimport', 'tool_analytics'); + list($modeldata, $mlbackenddir) = $this->extract_import_contents($zipfilepath); + + $target = \core_analytics\manager::get_target($modeldata->target); + $indicators = []; + foreach ($modeldata->indicators as $indicatorclass) { + $indicator = \core_analytics\manager::get_indicator($indicatorclass); + $indicators[$indicator->get_id()] = $indicator; + } + $model = \core_analytics\model::create($target, $indicators, $modeldata->timesplitting, $modeldata->processor); + + // Import them disabled. + $model->update(false, false, false, false); + + if ($mlbackenddir) { + $modeldir = $model->get_output_dir(['execution']); + if (!$model->get_predictions_processor(true)->import($model->get_unique_id(), $modeldir, $mlbackenddir)) { + throw new \moodle_exception('errorimport', 'analytics'); + } + $model->mark_as_trained(); } - if (empty($modeldata->target) || empty($modeldata->timesplitting) || empty($modeldata->indicators)) { - return get_string('errorimport', 'tool_analytics'); - } - - return null; + return $model; } /** * Check that the provided model configuration can be deployed in this site. * - * @param \stdClass $importmodel + * @param \stdClass $modeldata * @param bool $ignoreversionmismatches * @return string|null Error string or null if all good. */ - public function check_dependencies(\stdClass $importmodel, bool $ignoreversionmismatches) : ?string { + public function check_dependencies(\stdClass $modeldata, bool $ignoreversionmismatches) : ?string { $siteversions = \core_component::get_all_versions(); @@ -153,7 +153,7 @@ class model_config { $missingclasses = []; // We first check that this site has the required dependencies and the required versions. - foreach ($importmodel->dependencies as $component => $importversion) { + foreach ($modeldata->dependencies as $component => $importversion) { if (empty($siteversions[$component])) { @@ -177,44 +177,42 @@ class model_config { } } - // Checking that the each of the components is available. - if (!$target = manager::get_target($importmodel->target)) { - $missingclasses[] = $importmodel->target; + // Checking that each of the components is available. + if (!$target = manager::get_target($modeldata->target)) { + $missingclasses[] = $modeldata->target; } - if (!$timesplitting = manager::get_time_splitting($importmodel->timesplitting)) { - $missingclasses[] = $importmodel->timesplitting; + if (!$timesplitting = manager::get_time_splitting($modeldata->timesplitting)) { + $missingclasses[] = $modeldata->timesplitting; } // Indicators. - $indicators = []; - foreach ($importmodel->indicators as $indicatorclass) { + foreach ($modeldata->indicators as $indicatorclass) { if (!$indicator = manager::get_indicator($indicatorclass)) { $missingclasses[] = $indicatorclass; } } // ML backend. - if (!empty($importmodel->processor)) { - if (!$processor = \core_analytics\manager::get_predictions_processor($importmodel->processor, false)) { + if (!empty($modeldata->processor)) { + if (!$processor = \core_analytics\manager::get_predictions_processor($modeldata->processor, false)) { $missingclasses[] = $indicatorclass; } } if (!empty($missingcomponents)) { - return get_string('errorimportmissingcomponents', 'tool_analytics', join(', ', $missingcomponents)); + return get_string('errorimportmissingcomponents', 'analytics', join(', ', $missingcomponents)); } if (!empty($versionmismatches)) { - return get_string('errorimportversionmismatches', 'tool_analytics', implode(', ', $versionmismatches)); + return get_string('errorimportversionmismatches', 'analytics', implode(', ', $versionmismatches)); } if (!empty($missingclasses)) { $a = (object)[ 'missingclasses' => implode(', ', $missingclasses), - 'dependencyversions' => implode(', ', $dependencyversions) ]; - return get_string('errorimportmissingclasses', 'tool_analytics', $a); + return get_string('errorimportmissingclasses', 'analytics', $a); } // No issues found. @@ -248,4 +246,81 @@ class model_config { return $component; } + + /** + * Extracts the import zip contents. + * + * @param string $zipfilepath Zip file path + * @return array [0] => \stdClass, [1] => string + */ + public function extract_import_contents(string $zipfilepath) : array { + + $importtempdir = make_request_directory('analyticsimport' . microtime(false)); + + $zip = new \zip_packer(); + $filelist = $zip->extract_to_pathname($zipfilepath, $importtempdir); + + if (empty($filelist[self::CONFIG_FILE_NAME])) { + // Missing required file. + throw new \moodle_exception('errorimport', 'analytics'); + } + + $jsonmodeldata = file_get_contents($importtempdir . DIRECTORY_SEPARATOR . self::CONFIG_FILE_NAME); + + if (!$modeldata = json_decode($jsonmodeldata)) { + throw new \moodle_exception('errorimport', 'analytics'); + } + + if (empty($modeldata->target) || empty($modeldata->timesplitting) || empty($modeldata->indicators)) { + throw new \moodle_exception('errorimport', 'analytics'); + } + + $mlbackenddir = $importtempdir . DIRECTORY_SEPARATOR . 'mlbackend'; + if (!is_dir($mlbackenddir)) { + $mlbackenddir = false; + } + + return [$modeldata, $mlbackenddir]; + } + /** + * Exports the configuration of the model. + * @return \stdClass + */ + protected function export_model_data() : \stdClass { + + $versions = \core_component::get_all_versions(); + + $data = new \stdClass(); + + // Target. + $data->target = $this->model->get_target()->get_id(); + $requiredclasses[] = $data->target; + + // Time splitting method. + $data->timesplitting = $this->model->get_time_splitting()->get_id(); + $requiredclasses[] = $data->timesplitting; + + // Model indicators. + $data->indicators = []; + foreach ($this->model->get_indicators() as $indicator) { + $indicatorid = $indicator->get_id(); + $data->indicators[] = $indicatorid; + $requiredclasses[] = $indicatorid; + } + + // Return the predictions processor this model is using, even if no predictions processor + // was explicitly selected. + $predictionsprocessor = $this->model->get_predictions_processor(); + $data->processor = '\\' . get_class($predictionsprocessor); + $requiredclasses[] = $data->processor; + + // Add information for versioning. + $data->dependencies = []; + foreach ($requiredclasses as $fullclassname) { + $component = $this->get_class_component($fullclassname); + $data->dependencies[$component] = $versions[$component]; + } + + return $data; + } } diff --git a/analytics/classes/packable.php b/analytics/classes/packable.php new file mode 100644 index 00000000000..1d26f65f479 --- /dev/null +++ b/analytics/classes/packable.php @@ -0,0 +1,57 @@ +. + +/** + * Exportable machine learning backend interface. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_analytics; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Exportable machine learning backend interface. + * + * @package core_analytics + * @copyright 2019 David Monllao {@link http://www.davidmonllao.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface packable { + + /** + * Exports the machine learning model. + * + * @throws \moodle_exception + * @param string $uniqueid The model unique id + * @param string $modeldir The directory that contains the trained model. + * @return string The path to the directory that contains the exported model. + */ + public function export(string $uniqueid, string $modeldir) : string; + + /** + * Imports the provided machine learning model. + * + * @param string $uniqueid The model unique id + * @param string $modeldir The directory that will contain the trained model. + * @param string $importdir The directory that contains the files to import. + * @return bool Success + */ + public function import(string $uniqueid, string $modeldir, string $importdir) : bool; +} diff --git a/analytics/tests/model_test.php b/analytics/tests/model_test.php index 782c916c899..19ceb6166a0 100644 --- a/analytics/tests/model_test.php +++ b/analytics/tests/model_test.php @@ -337,36 +337,24 @@ class analytics_model_testcase extends advanced_testcase { } /** - * Test export_from_json() API. + * Test that import_model import models' configurations. */ - public function test_create_from_import() { + public function test_import_model_config() { $this->resetAfterTest(true); $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); + $zipfilepath = $this->model->export_model('yeah-config.zip'); $this->modelobj = $this->model->get_model_obj(); - $modelconfig = new \core_analytics\model_config($this->model); - $modeldata = $modelconfig->export(); - $importedmodel = \core_analytics\model::create_from_import($modeldata)->get_model_obj(); + $importedmodelobj = \core_analytics\model::import_model($zipfilepath)->get_model_obj(); - $this->assertSame($this->modelobj->target, $importedmodel->target); - $this->assertSame($this->modelobj->indicators, $importedmodel->indicators); - $this->assertSame($this->modelobj->timesplitting, $importedmodel->timesplitting); - $this->assertEmpty($importedmodel->predictionsprocessor); + $this->assertSame($this->modelobj->target, $importedmodelobj->target); + $this->assertSame($this->modelobj->indicators, $importedmodelobj->indicators); + $this->assertSame($this->modelobj->timesplitting, $importedmodelobj->timesplitting); - $this->model->update(true, false, false, '\\mlbackend_php\\processor'); - - $this->modelobj = $this->model->get_model_obj(); - - $modelconfig = new \core_analytics\model_config($this->model); - $modeldata = $modelconfig->export(); - $importedmodel = \core_analytics\model::create_from_import($modeldata)->get_model_obj(); - - $this->assertSame($this->modelobj->target, $importedmodel->target); - $this->assertSame($this->modelobj->indicators, $importedmodel->indicators); - $this->assertSame($this->modelobj->timesplitting, $importedmodel->timesplitting); - $this->assertSame($this->modelobj->predictionsprocessor, $importedmodel->predictionsprocessor); + $predictionsprocessor = $this->model->get_predictions_processor(); + $this->assertSame('\\' . get_class($predictionsprocessor), $importedmodelobj->predictionsprocessor); } /** @@ -399,7 +387,11 @@ class analytics_model_testcase extends advanced_testcase { $this->model->enable('\\core\\analytics\\time_splitting\\quarters'); $modelconfig = new \core_analytics\model_config($this->model); - $modeldata = $modelconfig->export(); + + $method = new ReflectionMethod('\\core_analytics\\model_config', 'export_model_data'); + $method->setAccessible(true); + + $modeldata = $method->invoke($modelconfig); $this->assertArrayHasKey('core', $modeldata->dependencies); $this->assertInternalType('float', $modeldata->dependencies['core']); @@ -409,7 +401,8 @@ class analytics_model_testcase extends advanced_testcase { $indicators['test_indicator_max'] = \core_analytics\manager::get_indicator('test_indicator_max'); $this->model->update(true, $indicators, false); - $modeldata = $modelconfig->export(); + + $modeldata = $method->invoke($modelconfig); $this->assertCount(1, $modeldata->indicators); } @@ -443,17 +436,6 @@ class analytics_model_testcase extends advanced_testcase { */ class testable_model extends \core_analytics\model { - /** - * get_output_dir - * - * @param array $subdirs - * @param bool $onlymodelid - * @return string - */ - public function get_output_dir($subdirs = array(), $onlymodelid = false) { - return parent::get_output_dir($subdirs, $onlymodelid); - } - /** * init_analyser * diff --git a/analytics/tests/prediction_test.php b/analytics/tests/prediction_test.php index 7d74868013e..18582c32c1c 100644 --- a/analytics/tests/prediction_test.php +++ b/analytics/tests/prediction_test.php @@ -119,23 +119,9 @@ class core_analytics_prediction_testcase extends advanced_testcase { $this->setAdminuser(); set_config('enabled_stores', 'logstore_standard', 'tool_log'); - $ncourses = 10; - // Generate training data. - $params = array( - 'startdate' => mktime(0, 0, 0, 10, 24, 2015), - 'enddate' => mktime(0, 0, 0, 2, 24, 2016), - ); - for ($i = 0; $i < $ncourses; $i++) { - $name = 'a' . random_string(10); - $courseparams = array('shortname' => $name, 'fullname' => $name) + $params; - $this->getDataGenerator()->create_course($courseparams); - } - for ($i = 0; $i < $ncourses; $i++) { - $name = 'b' . random_string(10); - $courseparams = array('shortname' => $name, 'fullname' => $name) + $params; - $this->getDataGenerator()->create_course($courseparams); - } + $ncourses = 10; + $this->generate_courses($ncourses); // We repeat the test for all prediction processors. $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); @@ -171,6 +157,10 @@ class core_analytics_prediction_testcase extends advanced_testcase { $this->assertEmpty($fs->get_directory_files(\context_system::instance()->id, 'analytics', \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false)); + $params = [ + 'startdate' => mktime(0, 0, 0, 10, 24, 2015), + 'enddate' => mktime(0, 0, 0, 2, 24, 2016), + ]; $courseparams = $params + array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0); $course1 = $this->getDataGenerator()->create_course($courseparams); $courseparams = $params + array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0); @@ -280,6 +270,67 @@ class core_analytics_prediction_testcase extends advanced_testcase { return $this->add_prediction_processors($cases); } + /** + * test_ml_export_import + * + * @param string $predictionsprocessorclass The class name + * @dataProvider provider_ml_export_import + */ + public function test_ml_export_import($predictionsprocessorclass) { + + $this->resetAfterTest(true); + $this->setAdminuser(); + set_config('enabled_stores', 'logstore_standard', 'tool_log'); + + // Generate training data. + $ncourses = 10; + $this->generate_courses($ncourses); + + // We repeat the test for all prediction processors. + $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); + if ($predictionsprocessor->is_ready() !== true) { + $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.'); + } + + $model = $this->add_perfect_model(); + $model->update(true, false, '\core\analytics\time_splitting\quarters', get_class($predictionsprocessor)); + + $model->train(); + + $this->generate_courses(10, ['visible' => 0]); + + $originalresults = $model->predict(); + + $zipfilename = 'model-zip-' . microtime() . '.zip'; + $zipfilepath = $model->export_model($zipfilename); + + $importmodel = \core_analytics\model::import_model($zipfilepath); + $importmodel->enable(); + + // Now predict using the imported model without prior training. + $importedmodelresults = $importmodel->predict(); + + foreach ($originalresults->predictions as $sampleid => $prediction) { + $this->assertEquals($importedmodelresults->predictions[$sampleid]->prediction, $prediction->prediction); + } + + set_config('enabled_stores', '', 'tool_log'); + get_log_manager(true); + } + + /** + * provider_ml_export_import + * + * @return array + */ + public function provider_ml_export_import() { + $cases = [ + 'case' => [], + ]; + + // We need to test all system prediction processors. + return $this->add_prediction_processors($cases); + } /** * Test the system classifiers returns. * @@ -400,20 +451,7 @@ class core_analytics_prediction_testcase extends advanced_testcase { } // Generate training data. - $params = array( - 'startdate' => mktime(0, 0, 0, 10, 24, 2015), - 'enddate' => mktime(0, 0, 0, 2, 24, 2016), - ); - for ($i = 0; $i < $ncourses; $i++) { - $name = 'a' . random_string(10); - $params = array('shortname' => $name, 'fullname' => $name) + $params; - $this->getDataGenerator()->create_course($params); - } - for ($i = 0; $i < $ncourses; $i++) { - $name = 'b' . random_string(10); - $params = array('shortname' => $name, 'fullname' => $name) + $params; - $this->getDataGenerator()->create_course($params); - } + $this->generate_courses($ncourses); // We repeat the test for all prediction processors. $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false); @@ -579,6 +617,32 @@ class core_analytics_prediction_testcase extends advanced_testcase { return new \core_analytics\model($model->get_id()); } + /** + * Generates $ncourses courses + * + * @param int $ncourses The number of courses to be generated. + * @param array $params Course params + * @return null + */ + protected function generate_courses($ncourses, array $params = []) { + + $params = $params + [ + 'startdate' => mktime(0, 0, 0, 10, 24, 2015), + 'enddate' => mktime(0, 0, 0, 2, 24, 2016), + ]; + + for ($i = 0; $i < $ncourses; $i++) { + $name = 'a' . random_string(10); + $courseparams = array('shortname' => $name, 'fullname' => $name) + $params; + $this->getDataGenerator()->create_course($courseparams); + } + for ($i = 0; $i < $ncourses; $i++) { + $name = 'b' . random_string(10); + $courseparams = array('shortname' => $name, 'fullname' => $name) + $params; + $this->getDataGenerator()->create_course($courseparams); + } + } + /** * add_prediction_processors * diff --git a/lang/en/analytics.php b/lang/en/analytics.php index bc843f8dc15..0604129df39 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -38,8 +38,16 @@ $string['erroralreadypredict'] = 'File {$a} has already been used to generate pr $string['errorcannotreaddataset'] = 'Dataset file {$a} can not be read'; $string['errorcannotwritedataset'] = 'Dataset file {$a} cannot be written'; $string['errorendbeforestart'] = 'The end date ({$a}) is before the course start date.'; +$string['errorexportmodelresult'] = 'The machine learning model can not be exported.'; +$string['errorimport'] = 'Error importing the provided json file.'; +$string['errorimportmissingcomponents'] = 'The provided model requires the following plugins to be installed: {$a}. Note that the versions do not necessarily need to match with the versions installed in your system. To install the same or a newer version of the plugin should be enough in most cases.'; +$string['errorimportversionmismatches'] = 'The version of the following components differ from the version installed in this site: {$a}. You can use "Ignore version mismatches" option to ignore these differences.'; +$string['errorimportmissingclasses'] = 'The following analytics components are not available in this site: {$a->missingclasses}. '; $string['errorinvalidindicator'] = 'Invalid {$a} indicator'; +$string['errorinvalidtarget'] = 'Invalid {$a} target'; $string['errorinvalidtimesplitting'] = 'Invalid time splitting; please ensure you add the class fully qualified class name.'; +$string['errornoexportconfig'] = 'There was a problem exporting the model configuration.'; +$string['errornoexportconfigrequirements'] = 'Only non static models with timeplitting methods can be exported.'; $string['errornoindicators'] = 'This model does not have any indicators.'; $string['errornopredictresults'] = 'No results returned from the predictions processor. Check the output directory contents for more information.'; $string['errornotimesplittings'] = 'This model does not have any time-splitting method.'; diff --git a/lib/mlbackend/php/classes/processor.php b/lib/mlbackend/php/classes/processor.php index cfba5933860..445735031c2 100644 --- a/lib/mlbackend/php/classes/processor.php +++ b/lib/mlbackend/php/classes/processor.php @@ -38,7 +38,7 @@ use Phpml\ModelManager; * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class processor implements \core_analytics\classifier, \core_analytics\regressor { +class processor implements \core_analytics\classifier, \core_analytics\regressor, \core_analytics\packable { /** * Size of training / prediction batches. @@ -103,8 +103,7 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor */ public function train_classification($uniqueid, \stored_file $dataset, $outputdir) { - // Output directory is already unique to the model. - $modelfilepath = $outputdir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME; + $modelfilepath = $this->get_model_filepath($outputdir); $modelmanager = new ModelManager(); @@ -175,8 +174,7 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor */ public function classify($uniqueid, \stored_file $dataset, $outputdir) { - // Output directory is already unique to the model. - $modelfilepath = $outputdir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME; + $modelfilepath = $this->get_model_filepath($outputdir); if (!file_exists($modelfilepath)) { throw new \moodle_exception('errorcantloadmodel', 'mlbackend_php', '', $modelfilepath); @@ -424,6 +422,77 @@ class processor implements \core_analytics\classifier, \core_analytics\regressor throw new \coding_exception('This predictor does not support regression yet.'); } + /** + * Exports the machine learning model. + * + * @throws \moodle_exception + * @param string $uniqueid The model unique id + * @param string $modeldir The directory that contains the trained model. + * @return string The path to the directory that contains the exported model. + */ + public function export(string $uniqueid, string $modeldir) : string { + + $modelfilepath = $this->get_model_filepath($modeldir); + + if (!file_exists($modelfilepath)) { + throw new \moodle_exception('errorexportmodelresult', 'analytics'); + } + + // We can use the actual $modeldir as the directory is not modified during export, just copied into a zip. + return $modeldir; + } + + /** + * Imports the provided machine learning model. + * + * @param string $uniqueid The model unique id + * @param string $modeldir The directory that will contain the trained model. + * @param string $importdir The directory that contains the files to import. + * @return bool Success + */ + public function import(string $uniqueid, string $modeldir, string $importdir) : bool { + + $importmodelfilepath = $this->get_model_filepath($importdir); + $modelfilepath = $this->get_model_filepath($modeldir); + + $modelmanager = new ModelManager(); + + // Copied from ModelManager::restoreFromFile to validate the serialised contents + // before restoring them. + $importconfig = file_get_contents($importmodelfilepath); + + // Clean stuff like function calls. + $importconfig = preg_replace('/[^a-zA-Z0-9\{\}%\.\*\;\,\:\"\-\0\\\]/', '', $importconfig); + + $object = unserialize($importconfig, + ['allowed_classes' => ['Phpml\\Classification\\Linear\\LogisticRegression']]); + if (!$object) { + return false; + } + + if (get_class($object) == '__PHP_Incomplete_Class') { + return false; + } + + $classifier = $modelmanager->restoreFromFile($importmodelfilepath); + + // This would override any previous classifier. + $modelmanager->saveToFile($classifier, $modelfilepath); + + return true; + } + + /** + * Returns the path to the serialised model file in the provided directory. + * + * @param string $modeldir The model directory + * @return string The model file + */ + protected function get_model_filepath(string $modeldir) : string { + // Output directory is already unique to the model. + return $modeldir . DIRECTORY_SEPARATOR . self::MODEL_FILENAME; + } + /** * Returns the Phi correlation coefficient. * diff --git a/lib/mlbackend/python/classes/processor.php b/lib/mlbackend/python/classes/processor.php index 864f23e0491..3d77ff4ea32 100644 --- a/lib/mlbackend/python/classes/processor.php +++ b/lib/mlbackend/python/classes/processor.php @@ -33,7 +33,7 @@ defined('MOODLE_INTERNAL') || die(); * @copyright 2016 David Monllao {@link http://www.davidmonllao.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class processor implements \core_analytics\classifier, \core_analytics\regressor { +class processor implements \core_analytics\classifier, \core_analytics\regressor, \core_analytics\packable { /** * The required version of the python package that performs all calculations. @@ -263,6 +263,78 @@ class processor implements \core_analytics\classifier, \core_analytics\regresso return $resultobj; } + /** + * Exports the machine learning model. + * + * @throws \moodle_exception + * @param string $uniqueid The model unique id + * @param string $modeldir The directory that contains the trained model. + * @return string The path to the directory that contains the exported model. + */ + public function export(string $uniqueid, string $modeldir) : string { + + // We include an exporttmpdir as we want to be sure that the file is not deleted after the + // python process finishes. + $exporttmpdir = make_request_directory('mlbackend_python_export'); + + $cmd = "{$this->pathtopython} -m moodlemlbackend.export " . + escapeshellarg($uniqueid) . ' ' . + escapeshellarg($modeldir) . ' ' . + escapeshellarg($exporttmpdir); + + if (!PHPUNIT_TEST && CLI_SCRIPT) { + debugging($cmd, DEBUG_DEVELOPER); + } + + $output = null; + $exitcode = null; + $exportdir = exec($cmd, $output, $exitcode); + + if ($exitcode != 0) { + throw new \moodle_exception('errorexportmodelresult', 'analytics'); + } + + if (!$exportdir) { + throw new \moodle_exception('errorexportmodelresult', 'analytics'); + } + + return $exportdir; + } + + /** + * Imports the provided machine learning model. + * + * @param string $uniqueid The model unique id + * @param string $modeldir The directory that will contain the trained model. + * @param string $importdir The directory that contains the files to import. + * @return bool Success + */ + public function import(string $uniqueid, string $modeldir, string $importdir) : bool { + + $cmd = "{$this->pathtopython} -m moodlemlbackend.import " . + escapeshellarg($uniqueid) . ' ' . + escapeshellarg($modeldir) . ' ' . + escapeshellarg($importdir); + + if (!PHPUNIT_TEST && CLI_SCRIPT) { + debugging($cmd, DEBUG_DEVELOPER); + } + + $output = null; + $exitcode = null; + $success = exec($cmd, $output, $exitcode); + + if ($exitcode != 0) { + throw new \moodle_exception('errorimportmodelresult', 'analytics'); + } + + if (!$success) { + throw new \moodle_exception('errorimportmodelresult', 'analytics'); + } + + return $success; + } + /** * Train this processor regression model using the provided supervised learning dataset. *