Commit c70a7194 authored by David Monllaó's avatar David Monllaó
Browse files

MDL-60944 analytics: Include trained ML models

parent e4453adc
......@@ -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'),
......
......@@ -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) {
......
......@@ -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();
......@@ -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);
......
......@@ -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.';
......
......@@ -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':
......
......@@ -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);
}
/**
......
......@@ -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();
$data = new \stdClass();
// Target.
$data->target = $this->model->get_target()->get_id();
$requiredclasses[] = $data->target;
$zip = new \zip_packer();
$zipfiles = [];
// Time splitting method.
$data->timesplitting = $this->model->get_time_splitting()->get_id();
$requiredclasses[] = $data->timesplitting;
// Model config in JSON.
$modeldata = $this->export_model_data();
// Model indicators.
$data->indicators = [];
foreach ($this->model->get_indicators() as $indicator) {
$indicatorid = $indicator->get_id();
$data->indicators[] = $indicatorid;
$requiredclasses[] = $indicatorid;
$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;
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];
// 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;
}
}
return $data;
$zipfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . $zipfilename;
$zip->archive_to_pathname($zipfiles, $zipfilepath);
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.
* Note that this method assumes that self::check_dependencies has already been called.
*
* @param string $zipfilepath Path to the zip file to import
* @return \core_analytics\model
*/
public function export_to_file(\stdClass $data, string $downloadfilename) : string {
public function import(string $zipfilepath) : \core_analytics\model {
$modelconfig = json_encode($data);
list($modeldata, $mlbackenddir) = $this->extract_import_contents($zipfilepath);
$dir = make_temp_directory('analyticsexport');
$filepath = $dir . DIRECTORY_SEPARATOR . $downloadfilename;
if (!file_put_contents($filepath, $modelconfig)) {
print_error('errornoexportconfig', 'tool_analytics');
$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);
return $filepath;
}
/**
* Check the provided json string.
*
* @param string $json A json string.
* @return string|null Error string or null if all good.
*/
public function check_json_data(string $json) : ?string {
if (!$modeldata = json_decode($json)) {
return get_string('errorimport', 'tool_analytics');
}
// Import them disabled.
$model->update(false, false, false, false);
if (empty($modeldata->target) || empty($modeldata->timesplitting) || empty($modeldata->indicators)) {
return get_string('errorimport', 'tool_analytics');
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();
}
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;
}
}
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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;
}
......@@ -337,36 +337,24 @@ class analytics_model_testcase extends advanced_testcase {
}