You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1805 lines
63 KiB
1805 lines
63 KiB
<?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/>.
|
|
|
|
/**
|
|
* Prediction model representation.
|
|
*
|
|
* @package core_analytics
|
|
* @copyright 2016 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();
|
|
|
|
/**
|
|
* Prediction model representation.
|
|
*
|
|
* @package core_analytics
|
|
* @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
|
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
|
*/
|
|
class model {
|
|
|
|
/**
|
|
* All as expected.
|
|
*/
|
|
const OK = 0;
|
|
|
|
/**
|
|
* There was a problem.
|
|
*/
|
|
const GENERAL_ERROR = 1;
|
|
|
|
/**
|
|
* No dataset to analyse.
|
|
*/
|
|
const NO_DATASET = 2;
|
|
|
|
/**
|
|
* Model with low prediction accuracy.
|
|
*/
|
|
const LOW_SCORE = 4;
|
|
|
|
/**
|
|
* Not enough data to evaluate the model properly.
|
|
*/
|
|
const NOT_ENOUGH_DATA = 8;
|
|
|
|
/**
|
|
* Invalid analysable for the time splitting method.
|
|
*/
|
|
const ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD = 4;
|
|
|
|
/**
|
|
* Invalid analysable for all time splitting methods.
|
|
*/
|
|
const ANALYSABLE_STATUS_INVALID_FOR_RANGEPROCESSORS = 8;
|
|
|
|
/**
|
|
* Invalid analysable for the target
|
|
*/
|
|
const ANALYSABLE_STATUS_INVALID_FOR_TARGET = 16;
|
|
|
|
/**
|
|
* Minimum score to consider a non-static prediction model as good.
|
|
*/
|
|
const MIN_SCORE = 0.7;
|
|
|
|
/**
|
|
* Minimum prediction confidence (from 0 to 1) to accept a prediction as reliable enough.
|
|
*/
|
|
const PREDICTION_MIN_SCORE = 0.6;
|
|
|
|
/**
|
|
* Maximum standard deviation between different evaluation repetitions to consider that evaluation results are stable.
|
|
*/
|
|
const ACCEPTED_DEVIATION = 0.05;
|
|
|
|
/**
|
|
* Number of evaluation repetitions.
|
|
*/
|
|
const EVALUATION_ITERATIONS = 10;
|
|
|
|
/**
|
|
* @var \stdClass
|
|
*/
|
|
protected $model = null;
|
|
|
|
/**
|
|
* @var \core_analytics\local\analyser\base
|
|
*/
|
|
protected $analyser = null;
|
|
|
|
/**
|
|
* @var \core_analytics\local\target\base
|
|
*/
|
|
protected $target = null;
|
|
|
|
/**
|
|
* @var \core_analytics\predictor
|
|
*/
|
|
protected $predictionsprocessor = null;
|
|
|
|
/**
|
|
* @var \core_analytics\local\indicator\base[]
|
|
*/
|
|
protected $indicators = null;
|
|
|
|
/**
|
|
* Unique Model id created from site info and last model modification.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $uniqueid = null;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param int|\stdClass $model
|
|
* @return void
|
|
*/
|
|
public function __construct($model) {
|
|
global $DB;
|
|
|
|
if (is_scalar($model)) {
|
|
$model = $DB->get_record('analytics_models', array('id' => $model), '*', MUST_EXIST);
|
|
if (!$model) {
|
|
throw new \moodle_exception('errorunexistingmodel', 'analytics', '', $model);
|
|
}
|
|
}
|
|
$this->model = $model;
|
|
}
|
|
|
|
/**
|
|
* Quick safety check to discard site models which required components are not available anymore.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function is_available() {
|
|
$target = $this->get_target();
|
|
if (!$target) {
|
|
return false;
|
|
}
|
|
|
|
$classname = $target->get_analyser_class();
|
|
if (!class_exists($classname)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Returns the model id.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function get_id() {
|
|
return $this->model->id;
|
|
}
|
|
|
|
/**
|
|
* Returns a plain \stdClass with the model data.
|
|
*
|
|
* @return \stdClass
|
|
*/
|
|
public function get_model_obj() {
|
|
return $this->model;
|
|
}
|
|
|
|
/**
|
|
* Returns the model target.
|
|
*
|
|
* @return \core_analytics\local\target\base
|
|
*/
|
|
public function get_target() {
|
|
if ($this->target !== null) {
|
|
return $this->target;
|
|
}
|
|
$instance = \core_analytics\manager::get_target($this->model->target);
|
|
$this->target = $instance;
|
|
|
|
return $this->target;
|
|
}
|
|
|
|
/**
|
|
* Returns the model indicators.
|
|
*
|
|
* @return \core_analytics\local\indicator\base[]
|
|
*/
|
|
public function get_indicators() {
|
|
if ($this->indicators !== null) {
|
|
return $this->indicators;
|
|
}
|
|
|
|
$fullclassnames = json_decode($this->model->indicators);
|
|
|
|
if (!is_array($fullclassnames)) {
|
|
throw new \coding_exception('Model ' . $this->model->id . ' indicators can not be read');
|
|
}
|
|
|
|
$this->indicators = array();
|
|
foreach ($fullclassnames as $fullclassname) {
|
|
$instance = \core_analytics\manager::get_indicator($fullclassname);
|
|
if ($instance) {
|
|
$this->indicators[$fullclassname] = $instance;
|
|
} else {
|
|
debugging('Can\'t load ' . $fullclassname . ' indicator', DEBUG_DEVELOPER);
|
|
}
|
|
}
|
|
|
|
return $this->indicators;
|
|
}
|
|
|
|
/**
|
|
* Returns the list of indicators that could potentially be used by the model target.
|
|
*
|
|
* It includes the indicators that are part of the model.
|
|
*
|
|
* @return \core_analytics\local\indicator\base[]
|
|
*/
|
|
public function get_potential_indicators() {
|
|
|
|
$indicators = \core_analytics\manager::get_all_indicators();
|
|
|
|
if (empty($this->analyser)) {
|
|
$this->init_analyser(array('evaluation' => true));
|
|
}
|
|
|
|
foreach ($indicators as $classname => $indicator) {
|
|
if ($this->analyser->check_indicator_requirements($indicator) !== true) {
|
|
unset($indicators[$classname]);
|
|
}
|
|
}
|
|
return $indicators;
|
|
}
|
|
|
|
/**
|
|
* Returns the model analyser (defined by the model target).
|
|
*
|
|
* @param array $options Default initialisation with no options.
|
|
* @return \core_analytics\local\analyser\base
|
|
*/
|
|
public function get_analyser($options = array()) {
|
|
if ($this->analyser !== null) {
|
|
return $this->analyser;
|
|
}
|
|
|
|
$this->init_analyser($options);
|
|
|
|
return $this->analyser;
|
|
}
|
|
|
|
/**
|
|
* Initialises the model analyser.
|
|
*
|
|
* @throws \coding_exception
|
|
* @param array $options
|
|
* @return void
|
|
*/
|
|
protected function init_analyser($options = array()) {
|
|
|
|
$target = $this->get_target();
|
|
$indicators = $this->get_indicators();
|
|
|
|
if (empty($target)) {
|
|
throw new \moodle_exception('errornotarget', 'analytics');
|
|
}
|
|
|
|
$timesplittings = array();
|
|
if (empty($options['notimesplitting'])) {
|
|
if (!empty($options['evaluation'])) {
|
|
// The evaluation process will run using all available time splitting methods unless one is specified.
|
|
if (!empty($options['timesplitting'])) {
|
|
$timesplitting = \core_analytics\manager::get_time_splitting($options['timesplitting']);
|
|
$timesplittings = array($timesplitting->get_id() => $timesplitting);
|
|
} else {
|
|
$timesplittings = \core_analytics\manager::get_time_splitting_methods_for_evaluation();
|
|
}
|
|
} else {
|
|
|
|
if (empty($this->model->timesplitting)) {
|
|
throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
|
|
}
|
|
|
|
// Returned as an array as all actions (evaluation, training and prediction) go through the same process.
|
|
$timesplittings = array($this->model->timesplitting => $this->get_time_splitting());
|
|
}
|
|
|
|
if (empty($timesplittings)) {
|
|
throw new \moodle_exception('errornotimesplittings', 'analytics');
|
|
}
|
|
}
|
|
|
|
$classname = $target->get_analyser_class();
|
|
if (!class_exists($classname)) {
|
|
throw new \coding_exception($classname . ' class does not exists');
|
|
}
|
|
|
|
// Returns a \core_analytics\local\analyser\base class.
|
|
$this->analyser = new $classname($this->model->id, $target, $indicators, $timesplittings, $options);
|
|
}
|
|
|
|
/**
|
|
* Returns the model time splitting method.
|
|
*
|
|
* @return \core_analytics\local\time_splitting\base|false Returns false if no time splitting.
|
|
*/
|
|
public function get_time_splitting() {
|
|
if (empty($this->model->timesplitting)) {
|
|
return false;
|
|
}
|
|
return \core_analytics\manager::get_time_splitting($this->model->timesplitting);
|
|
}
|
|
|
|
/**
|
|
* Creates a new model. Enables it if $timesplittingid is specified.
|
|
*
|
|
* @param \core_analytics\local\target\base $target
|
|
* @param \core_analytics\local\indicator\base[] $indicators
|
|
* @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 = null) {
|
|
global $USER, $DB;
|
|
|
|
$indicatorclasses = self::indicator_classes($indicators);
|
|
|
|
$now = time();
|
|
|
|
$modelobj = new \stdClass();
|
|
$modelobj->target = $target->get_id();
|
|
$modelobj->indicators = json_encode($indicatorclasses);
|
|
$modelobj->version = $now;
|
|
$modelobj->timecreated = $now;
|
|
$modelobj->timemodified = $now;
|
|
$modelobj->usermodified = $USER->id;
|
|
|
|
if ($target->based_on_assumptions()) {
|
|
$modelobj->trained = 1;
|
|
}
|
|
|
|
if ($timesplittingid) {
|
|
if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) {
|
|
throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
|
|
}
|
|
if (substr($timesplittingid, 0, 1) !== '\\') {
|
|
throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
|
|
}
|
|
$modelobj->timesplitting = $timesplittingid;
|
|
}
|
|
|
|
if ($processor &&
|
|
!manager::is_valid($processor, '\core_analytics\classifier') &&
|
|
!manager::is_valid($processor, '\core_analytics\regressor')) {
|
|
throw new \coding_exception('The provided predictions processor \\' . $processor . '\processor is not valid');
|
|
} else {
|
|
$modelobj->predictionsprocessor = $processor;
|
|
}
|
|
|
|
$id = $DB->insert_record('analytics_models', $modelobj);
|
|
|
|
// Get db defaults.
|
|
$modelobj = $DB->get_record('analytics_models', array('id' => $id), '*', MUST_EXIST);
|
|
|
|
$model = new static($modelobj);
|
|
|
|
return $model;
|
|
}
|
|
|
|
/**
|
|
* Does this model exist?
|
|
*
|
|
* If no indicators are provided it considers any model with the provided
|
|
* target a match.
|
|
*
|
|
* @param \core_analytics\local\target\base $target
|
|
* @param \core_analytics\local\indicator\base[]|false $indicators
|
|
* @return bool
|
|
*/
|
|
public static function exists(\core_analytics\local\target\base $target, $indicators = false) {
|
|
global $DB;
|
|
|
|
$existingmodels = $DB->get_records('analytics_models', array('target' => $target->get_id()));
|
|
|
|
if (!$existingmodels) {
|
|
return false;
|
|
}
|
|
|
|
if (!$indicators && $existingmodels) {
|
|
return true;
|
|
}
|
|
|
|
$indicatorids = array_keys($indicators);
|
|
sort($indicatorids);
|
|
|
|
foreach ($existingmodels as $modelobj) {
|
|
$model = new \core_analytics\model($modelobj);
|
|
$modelindicatorids = array_keys($model->get_indicators());
|
|
sort($modelindicatorids);
|
|
|
|
if ($indicatorids === $modelindicatorids) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Updates the model.
|
|
*
|
|
* @param int|bool $enabled
|
|
* @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
|
|
* @param string|false $timesplittingid False to respect current time splitting method
|
|
* @param string|false $predictionsprocessor False to respect current predictors processor value
|
|
* @return void
|
|
*/
|
|
public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) {
|
|
global $USER, $DB;
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
$now = time();
|
|
|
|
if ($indicators !== false) {
|
|
$indicatorclasses = self::indicator_classes($indicators);
|
|
$indicatorsstr = json_encode($indicatorclasses);
|
|
} else {
|
|
// Respect current value.
|
|
$indicatorsstr = $this->model->indicators;
|
|
}
|
|
|
|
if ($timesplittingid === false) {
|
|
// Respect current value.
|
|
$timesplittingid = $this->model->timesplitting;
|
|
}
|
|
|
|
if ($predictionsprocessor === false) {
|
|
// Respect current value.
|
|
$predictionsprocessor = $this->model->predictionsprocessor;
|
|
}
|
|
|
|
if ($this->model->timesplitting !== $timesplittingid ||
|
|
$this->model->indicators !== $indicatorsstr ||
|
|
$this->model->predictionsprocessor !== $predictionsprocessor) {
|
|
|
|
// Delete generated predictions before changing the model version.
|
|
$this->clear();
|
|
|
|
// It needs to be reset as the version changes.
|
|
$this->uniqueid = null;
|
|
$this->indicators = null;
|
|
|
|
// We update the version of the model so different time splittings are not mixed up.
|
|
$this->model->version = $now;
|
|
|
|
// Reset trained flag.
|
|
if (!$this->is_static()) {
|
|
$this->model->trained = 0;
|
|
}
|
|
|
|
} else if ($this->model->enabled != $enabled) {
|
|
// We purge the cached contexts with insights as some will not be visible anymore.
|
|
$this->purge_insights_cache();
|
|
}
|
|
|
|
$this->model->enabled = intval($enabled);
|
|
$this->model->indicators = $indicatorsstr;
|
|
$this->model->timesplitting = $timesplittingid;
|
|
$this->model->predictionsprocessor = $predictionsprocessor;
|
|
$this->model->timemodified = $now;
|
|
$this->model->usermodified = $USER->id;
|
|
|
|
$DB->update_record('analytics_models', $this->model);
|
|
}
|
|
|
|
/**
|
|
* Removes the model.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function delete() {
|
|
global $DB;
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
$this->clear();
|
|
|
|
// Method self::clear is already clearing the current model version.
|
|
$predictor = $this->get_predictions_processor(false);
|
|
if ($predictor->is_ready() !== true) {
|
|
$predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
|
|
debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
|
|
$this->model->id . ' could not be deleted.');
|
|
} else {
|
|
$predictor->delete_output_dir($this->get_output_dir(array(), true));
|
|
}
|
|
|
|
$DB->delete_records('analytics_models', array('id' => $this->model->id));
|
|
$DB->delete_records('analytics_models_log', array('modelid' => $this->model->id));
|
|
}
|
|
|
|
/**
|
|
* Evaluates the model.
|
|
*
|
|
* This method gets the site contents (through the analyser) creates a .csv dataset
|
|
* with them and evaluates the model prediction accuracy multiple times using the
|
|
* machine learning backend. It returns an object where the model score is the average
|
|
* prediction accuracy of all executed evaluations.
|
|
*
|
|
* @param array $options
|
|
* @return \stdClass[]
|
|
*/
|
|
public function evaluate($options = array()) {
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
if ($this->is_static()) {
|
|
$this->get_analyser()->add_log(get_string('noevaluationbasedassumptions', 'analytics'));
|
|
$result = new \stdClass();
|
|
$result->status = self::NO_DATASET;
|
|
return array($result);
|
|
}
|
|
|
|
$options['evaluation'] = true;
|
|
|
|
if (empty($options['mode'])) {
|
|
$options['mode'] = 'configuration';
|
|
}
|
|
|
|
switch ($options['mode']) {
|
|
case 'trainedmodel':
|
|
|
|
// We are only interested on the time splitting method used by the trained model.
|
|
$options['timesplitting'] = $this->model->timesplitting;
|
|
|
|
// Provide the trained model directory to the ML backend if that is what we want to evaluate.
|
|
$trainedmodeldir = $this->get_output_dir(['execution']);
|
|
break;
|
|
case 'configuration':
|
|
|
|
$trainedmodeldir = false;
|
|
break;
|
|
|
|
default:
|
|
throw new \moodle_exception('errorunknownaction', 'analytics');
|
|
}
|
|
|
|
$this->init_analyser($options);
|
|
|
|
if (empty($this->get_indicators())) {
|
|
throw new \moodle_exception('errornoindicators', 'analytics');
|
|
}
|
|
|
|
$this->heavy_duty_mode();
|
|
|
|
// Before get_labelled_data call so we get an early exception if it is not ready.
|
|
$predictor = $this->get_predictions_processor();
|
|
|
|
$datasets = $this->get_analyser()->get_labelled_data();
|
|
|
|
// No datasets generated.
|
|
if (empty($datasets)) {
|
|
$result = new \stdClass();
|
|
$result->status = self::NO_DATASET;
|
|
$result->info = $this->get_analyser()->get_logs();
|
|
return array($result);
|
|
}
|
|
|
|
if (!PHPUNIT_TEST && CLI_SCRIPT) {
|
|
echo PHP_EOL . get_string('processingsitecontents', 'analytics') . PHP_EOL;
|
|
}
|
|
|
|
$results = array();
|
|
foreach ($datasets as $timesplittingid => $dataset) {
|
|
|
|
$timesplitting = \core_analytics\manager::get_time_splitting($timesplittingid);
|
|
|
|
$result = new \stdClass();
|
|
|
|
$dashestimesplittingid = str_replace('\\', '', $timesplittingid);
|
|
$outputdir = $this->get_output_dir(array('evaluation', $dashestimesplittingid));
|
|
|
|
// Evaluate the dataset, the deviation we accept in the results depends on the amount of iterations.
|
|
if ($this->get_target()->is_linear()) {
|
|
$predictorresult = $predictor->evaluate_regression($this->get_unique_id(), self::ACCEPTED_DEVIATION,
|
|
self::EVALUATION_ITERATIONS, $dataset, $outputdir, $trainedmodeldir);
|
|
} else {
|
|
$predictorresult = $predictor->evaluate_classification($this->get_unique_id(), self::ACCEPTED_DEVIATION,
|
|
self::EVALUATION_ITERATIONS, $dataset, $outputdir, $trainedmodeldir);
|
|
}
|
|
|
|
$result->status = $predictorresult->status;
|
|
$result->info = $predictorresult->info;
|
|
|
|
if (isset($predictorresult->score)) {
|
|
$result->score = $predictorresult->score;
|
|
} else {
|
|
// Prediction processors may return an error, default to 0 score in that case.
|
|
$result->score = 0;
|
|
}
|
|
|
|
$dir = false;
|
|
if (!empty($predictorresult->dir)) {
|
|
$dir = $predictorresult->dir;
|
|
}
|
|
|
|
$result->logid = $this->log_result($timesplitting->get_id(), $result->score, $dir, $result->info, $options['mode']);
|
|
|
|
$results[$timesplitting->get_id()] = $result;
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Trains the model using the site contents.
|
|
*
|
|
* This method prepares a dataset from the site contents (through the analyser)
|
|
* and passes it to the machine learning backends. Static models are skipped as
|
|
* they do not require training.
|
|
*
|
|
* @return \stdClass
|
|
*/
|
|
public function train() {
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
if ($this->is_static()) {
|
|
$this->get_analyser()->add_log(get_string('notrainingbasedassumptions', 'analytics'));
|
|
$result = new \stdClass();
|
|
$result->status = self::OK;
|
|
return $result;
|
|
}
|
|
|
|
if (!$this->is_enabled() || empty($this->model->timesplitting)) {
|
|
throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
|
|
}
|
|
|
|
if (empty($this->get_indicators())) {
|
|
throw new \moodle_exception('errornoindicators', 'analytics');
|
|
}
|
|
|
|
$this->heavy_duty_mode();
|
|
|
|
// Before get_labelled_data call so we get an early exception if it is not writable.
|
|
$outputdir = $this->get_output_dir(array('execution'));
|
|
|
|
// Before get_labelled_data call so we get an early exception if it is not ready.
|
|
$predictor = $this->get_predictions_processor();
|
|
|
|
$datasets = $this->get_analyser()->get_labelled_data();
|
|
|
|
// No training if no files have been provided.
|
|
if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
|
|
|
|
$result = new \stdClass();
|
|
$result->status = self::NO_DATASET;
|
|
$result->info = $this->get_analyser()->get_logs();
|
|
return $result;
|
|
}
|
|
$samplesfile = $datasets[$this->model->timesplitting];
|
|
|
|
// Train using the dataset.
|
|
if ($this->get_target()->is_linear()) {
|
|
$predictorresult = $predictor->train_regression($this->get_unique_id(), $samplesfile, $outputdir);
|
|
} else {
|
|
$predictorresult = $predictor->train_classification($this->get_unique_id(), $samplesfile, $outputdir);
|
|
}
|
|
|
|
$result = new \stdClass();
|
|
$result->status = $predictorresult->status;
|
|
$result->info = $predictorresult->info;
|
|
|
|
if ($result->status !== self::OK) {
|
|
return $result;
|
|
}
|
|
|
|
$this->flag_file_as_used($samplesfile, 'trained');
|
|
|
|
// Mark the model as trained if it wasn't.
|
|
if ($this->model->trained == false) {
|
|
$this->mark_as_trained();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Get predictions from the site contents.
|
|
*
|
|
* It analyses the site contents (through analyser classes) looking for samples
|
|
* ready to receive predictions. It generates a dataset with all samples ready to
|
|
* get predictions and it passes it to the machine learning backends or to the
|
|
* targets based on assumptions to get the predictions.
|
|
*
|
|
* @return \stdClass
|
|
*/
|
|
public function predict() {
|
|
global $DB;
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
if (!$this->is_enabled() || empty($this->model->timesplitting)) {
|
|
throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
|
|
}
|
|
|
|
if (empty($this->get_indicators())) {
|
|
throw new \moodle_exception('errornoindicators', 'analytics');
|
|
}
|
|
|
|
$this->heavy_duty_mode();
|
|
|
|
// Before get_unlabelled_data call so we get an early exception if it is not writable.
|
|
$outputdir = $this->get_output_dir(array('execution'));
|
|
|
|
if (!$this->is_static()) {
|
|
// Predictions using a machine learning backend.
|
|
|
|
// Before get_unlabelled_data call so we get an early exception if it is not ready.
|
|
$predictor = $this->get_predictions_processor();
|
|
|
|
$samplesdata = $this->get_analyser()->get_unlabelled_data();
|
|
|
|
// Get the prediction samples file.
|
|
if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
|
|
|
|
$result = new \stdClass();
|
|
$result->status = self::NO_DATASET;
|
|
$result->info = $this->get_analyser()->get_logs();
|
|
return $result;
|
|
}
|
|
$samplesfile = $samplesdata[$this->model->timesplitting];
|
|
|
|
// We need to throw an exception if we are trying to predict stuff that was already predicted.
|
|
$params = array('modelid' => $this->model->id, 'action' => 'predicted', 'fileid' => $samplesfile->get_id());
|
|
if ($predicted = $DB->get_record('analytics_used_files', $params)) {
|
|
throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id());
|
|
}
|
|
|
|
$indicatorcalculations = \core_analytics\dataset_manager::get_structured_data($samplesfile);
|
|
|
|
// Estimation and classification processes run on the machine learning backend side.
|
|
if ($this->get_target()->is_linear()) {
|
|
$predictorresult = $predictor->estimate($this->get_unique_id(), $samplesfile, $outputdir);
|
|
} else {
|
|
$predictorresult = $predictor->classify($this->get_unique_id(), $samplesfile, $outputdir);
|
|
}
|
|
|
|
// Prepare the results object.
|
|
$result = new \stdClass();
|
|
$result->status = $predictorresult->status;
|
|
$result->info = $predictorresult->info;
|
|
$result->predictions = $this->format_predictor_predictions($predictorresult);
|
|
|
|
} else {
|
|
// Predictions based on assumptions.
|
|
|
|
$indicatorcalculations = $this->get_analyser()->get_static_data();
|
|
// Get the prediction samples file.
|
|
if (empty($indicatorcalculations) || empty($indicatorcalculations[$this->model->timesplitting])) {
|
|
|
|
$result = new \stdClass();
|
|
$result->status = self::NO_DATASET;
|
|
$result->info = $this->get_analyser()->get_logs();
|
|
return $result;
|
|
}
|
|
|
|
// Same as reset($indicatorcalculations) as models based on assumptions only analyse 1 single
|
|
// time-splitting method.
|
|
$indicatorcalculations = $indicatorcalculations[$this->model->timesplitting];
|
|
|
|
// Prepare the results object.
|
|
$result = new \stdClass();
|
|
$result->status = self::OK;
|
|
$result->info = [];
|
|
$result->predictions = $this->get_static_predictions($indicatorcalculations);
|
|
}
|
|
|
|
if ($result->status !== self::OK) {
|
|
return $result;
|
|
}
|
|
|
|
if ($result->predictions) {
|
|
list($samplecontexts, $predictionrecords) = $this->execute_prediction_callbacks($result->predictions,
|
|
$indicatorcalculations);
|
|
}
|
|
|
|
if (!empty($samplecontexts) && $this->uses_insights()) {
|
|
$this->trigger_insights($samplecontexts, $predictionrecords);
|
|
}
|
|
|
|
if (!$this->is_static()) {
|
|
$this->flag_file_as_used($samplesfile, 'predicted');
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Returns the model predictions processor.
|
|
*
|
|
* @param bool $checkisready
|
|
* @return \core_analytics\predictor
|
|
*/
|
|
public function get_predictions_processor($checkisready = true) {
|
|
return manager::get_predictions_processor($this->model->predictionsprocessor, $checkisready);
|
|
}
|
|
|
|
/**
|
|
* Formats the predictor results.
|
|
*
|
|
* @param array $predictorresult
|
|
* @return array
|
|
*/
|
|
private function format_predictor_predictions($predictorresult) {
|
|
|
|
$predictions = array();
|
|
if (!empty($predictorresult->predictions)) {
|
|
foreach ($predictorresult->predictions as $sampleinfo) {
|
|
|
|
// We parse each prediction.
|
|
switch (count($sampleinfo)) {
|
|
case 1:
|
|
// For whatever reason the predictions processor could not process this sample, we
|
|
// skip it and do nothing with it.
|
|
debugging($this->model->id . ' model predictions processor could not process the sample with id ' .
|
|
$sampleinfo[0], DEBUG_DEVELOPER);
|
|
continue 2;
|
|
case 2:
|
|
// Prediction processors that do not return a prediction score will have the maximum prediction
|
|
// score.
|
|
list($uniquesampleid, $prediction) = $sampleinfo;
|
|
$predictionscore = 1;
|
|
break;
|
|
case 3:
|
|
list($uniquesampleid, $prediction, $predictionscore) = $sampleinfo;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
$predictiondata = (object)['prediction' => $prediction, 'predictionscore' => $predictionscore];
|
|
$predictions[$uniquesampleid] = $predictiondata;
|
|
}
|
|
}
|
|
return $predictions;
|
|
}
|
|
|
|
/**
|
|
* Execute the prediction callbacks defined by the target.
|
|
*
|
|
* @param \stdClass[] $predictions
|
|
* @param array $indicatorcalculations
|
|
* @return array
|
|
*/
|
|
protected function execute_prediction_callbacks(&$predictions, $indicatorcalculations) {
|
|
|
|
// Here we will store all predictions' contexts, this will be used to limit which users will see those predictions.
|
|
$samplecontexts = array();
|
|
$records = array();
|
|
|
|
foreach ($predictions as $uniquesampleid => $prediction) {
|
|
|
|
// The unique sample id contains both the sampleid and the rangeindex.
|
|
list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
|
|
if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) {
|
|
|
|
// Prepare the record to store the predicted values.
|
|
list($record, $samplecontext) = $this->prepare_prediction_record($sampleid, $rangeindex, $prediction->prediction,
|
|
$prediction->predictionscore, json_encode($indicatorcalculations[$uniquesampleid]));
|
|
|
|
// We will later bulk-insert them all.
|
|
$records[$uniquesampleid] = $record;
|
|
|
|
// Also store all samples context to later generate insights or whatever action the target wants to perform.
|
|
$samplecontexts[$samplecontext->id] = $samplecontext;
|
|
|
|
$this->get_target()->prediction_callback($this->model->id, $sampleid, $rangeindex, $samplecontext,
|
|
$prediction->prediction, $prediction->predictionscore);
|
|
}
|
|
}
|
|
|
|
if (!empty($records)) {
|
|
$this->save_predictions($records);
|
|
}
|
|
|
|
return [$samplecontexts, $records];
|
|
}
|
|
|
|
/**
|
|
* Generates insights and updates the cache.
|
|
*
|
|
* @param \context[] $samplecontexts
|
|
* @param \stdClass[] $predictionrecords
|
|
* @return void
|
|
*/
|
|
protected function trigger_insights($samplecontexts, $predictionrecords) {
|
|
|
|
// Notify the target that all predictions have been processed.
|
|
if ($this->get_analyser()::one_sample_per_analysable()) {
|
|
|
|
// We need to do something unusual here. self::save_predictions uses the bulk-insert function (insert_records()) for
|
|
// performance reasons and that function does not return us the inserted ids. We need to retrieve them from
|
|
// the database, and we need to do it using one single database query (for performance reasons as well).
|
|
$predictionrecords = $this->add_prediction_ids($predictionrecords);
|
|
|
|
// Get \core_analytics\prediction objects also fetching the samplesdata. This costs us
|
|
// 1 db read, but we have to pay it if we want that our insights include links to the
|
|
// suggested actions.
|
|
$predictions = array_map(function($predictionobj) {
|
|
$prediction = new \core_analytics\prediction($predictionobj, $this->prediction_sample_data($predictionobj));
|
|
return $prediction;
|
|
}, $predictionrecords);
|
|
} else {
|
|
$predictions = [];
|
|
}
|
|
|
|
$this->get_target()->generate_insight_notifications($this->model->id, $samplecontexts, $predictions);
|
|
|
|
// Update cache.
|
|
$cache = \cache::make('core', 'contextwithinsights');
|
|
foreach ($samplecontexts as $context) {
|
|
$modelids = $cache->get($context->id);
|
|
if (!$modelids) {
|
|
// The cache is empty, but we don't know if it is empty because there are no insights
|
|
// in this context or because cache/s have been purged, we need to be conservative and
|
|
// "pay" 1 db read to fill up the cache.
|
|
$models = \core_analytics\manager::get_models_with_insights($context);
|
|
$cache->set($context->id, array_keys($models));
|
|
} else if (!in_array($this->get_id(), $modelids)) {
|
|
array_push($modelids, $this->get_id());
|
|
$cache->set($context->id, $modelids);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get predictions from a static model.
|
|
*
|
|
* @param array $indicatorcalculations
|
|
* @return \stdClass[]
|
|
*/
|
|
protected function get_static_predictions(&$indicatorcalculations) {
|
|
|
|
$headers = array_shift($indicatorcalculations);
|
|
|
|
// Get rid of the sampleid header.
|
|
array_shift($headers);
|
|
|
|
// Group samples by analysable for \core_analytics\local\target::calculate.
|
|
$analysables = array();
|
|
// List all sampleids together.
|
|
$sampleids = array();
|
|
|
|
foreach ($indicatorcalculations as $uniquesampleid => $indicators) {
|
|
|
|
// Get rid of the sampleid column.
|
|
unset($indicators[0]);
|
|
$indicators = array_combine($headers, $indicators);
|
|
$indicatorcalculations[$uniquesampleid] = $indicators;
|
|
|
|
list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
|
|
|
|
$analysable = $this->get_analyser()->get_sample_analysable($sampleid);
|
|
$analysableclass = get_class($analysable);
|
|
if (empty($analysables[$analysableclass])) {
|
|
$analysables[$analysableclass] = array();
|
|
}
|
|
if (empty($analysables[$analysableclass][$rangeindex])) {
|
|
$analysables[$analysableclass][$rangeindex] = (object)[
|
|
'analysable' => $analysable,
|
|
'indicatorsdata' => array(),
|
|
'sampleids' => array()
|
|
];
|
|
}
|
|
|
|
// Using the sampleid as a key so we can easily merge indicators data later.
|
|
$analysables[$analysableclass][$rangeindex]->indicatorsdata[$sampleid] = $indicators;
|
|
// We could use indicatorsdata keys but the amount of redundant data is not that big and leaves code below cleaner.
|
|
$analysables[$analysableclass][$rangeindex]->sampleids[$sampleid] = $sampleid;
|
|
|
|
// Accumulate sample ids to get all their associated data in 1 single db query (analyser::get_samples).
|
|
$sampleids[$sampleid] = $sampleid;
|
|
}
|
|
|
|
// Get all samples data.
|
|
list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
|
|
|
|
// Calculate the targets.
|
|
$predictions = array();
|
|
foreach ($analysables as $analysableclass => $rangedata) {
|
|
foreach ($rangedata as $rangeindex => $data) {
|
|
|
|
// Attach samples data and calculated indicators data.
|
|
$this->get_target()->clear_sample_data();
|
|
$this->get_target()->add_sample_data($samplesdata);
|
|
$this->get_target()->add_sample_data($data->indicatorsdata);
|
|
|
|
// Append new elements (we can not get duplicates because sample-analysable relation is N-1).
|
|
$timesplitting = $this->get_time_splitting();
|
|
$timesplitting->set_modelid($this->get_id());
|
|
$timesplitting->set_analysable($data->analysable);
|
|
$range = $timesplitting->get_range_by_index($rangeindex);
|
|
|
|
$this->get_target()->filter_out_invalid_samples($data->sampleids, $data->analysable, false);
|
|
$calculations = $this->get_target()->calculate($data->sampleids, $data->analysable, $range['start'], $range['end']);
|
|
|
|
// Missing $indicatorcalculations values in $calculations are caused by is_valid_sample. We need to remove
|
|
// these $uniquesampleid from $indicatorcalculations because otherwise they will be stored as calculated
|
|
// by self::save_prediction.
|
|
$indicatorcalculations = array_filter($indicatorcalculations, function($indicators, $uniquesampleid)
|
|
use ($calculations, $rangeindex) {
|
|
list($sampleid, $indicatorsrangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid);
|
|
if ($rangeindex == $indicatorsrangeindex && !isset($calculations[$sampleid])) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}, ARRAY_FILTER_USE_BOTH);
|
|
|
|
foreach ($calculations as $sampleid => $value) {
|
|
|
|
$uniquesampleid = $this->get_time_splitting()->append_rangeindex($sampleid, $rangeindex);
|
|
|
|
// Null means that the target couldn't calculate the sample, we also remove them from $indicatorcalculations.
|
|
if (is_null($calculations[$sampleid])) {
|
|
unset($indicatorcalculations[$uniquesampleid]);
|
|
continue;
|
|
}
|
|
|
|
// Even if static predictions are based on assumptions we flag them as 100% because they are 100%
|
|
// true according to what the developer defined.
|
|
$predictions[$uniquesampleid] = (object)['prediction' => $value, 'predictionscore' => 1];
|
|
}
|
|
}
|
|
}
|
|
return $predictions;
|
|
}
|
|
|
|
/**
|
|
* Stores the prediction in the database.
|
|
*
|
|
* @param int $sampleid
|
|
* @param int $rangeindex
|
|
* @param int $prediction
|
|
* @param float $predictionscore
|
|
* @param string $calculations
|
|
* @return \context
|
|
*/
|
|
protected function prepare_prediction_record($sampleid, $rangeindex, $prediction, $predictionscore, $calculations) {
|
|
$context = $this->get_analyser()->sample_access_context($sampleid);
|
|
|
|
$record = new \stdClass();
|
|
$record->modelid = $this->model->id;
|
|
$record->contextid = $context->id;
|
|
$record->sampleid = $sampleid;
|
|
$record->rangeindex = $rangeindex;
|
|
$record->prediction = $prediction;
|
|
$record->predictionscore = $predictionscore;
|
|
$record->calculations = $calculations;
|
|
$record->timecreated = time();
|
|
|
|
$analysable = $this->get_analyser()->get_sample_analysable($sampleid);
|
|
$timesplitting = $this->get_time_splitting();
|
|
$timesplitting->set_modelid($this->get_id());
|
|
$timesplitting->set_analysable($analysable);
|
|
$range = $timesplitting->get_range_by_index($rangeindex);
|
|
if ($range) {
|
|
$record->timestart = $range['start'];
|
|
$record->timeend = $range['end'];
|
|
}
|
|
|
|
return array($record, $context);
|
|
}
|
|
|
|
/**
|
|
* Save the prediction objects.
|
|
*
|
|
* @param \stdClass[] $records
|
|
*/
|
|
protected function save_predictions($records) {
|
|
global $DB;
|
|
$DB->insert_records('analytics_predictions', $records);
|
|
}
|
|
|
|
/**
|
|
* Enabled the model using the provided time splitting method.
|
|
*
|
|
* @param string|false $timesplittingid False to respect the current time splitting method.
|
|
* @return void
|
|
*/
|
|
public function enable($timesplittingid = false) {
|
|
global $DB, $USER;
|
|
|
|
$now = time();
|
|
|
|
if ($timesplittingid && $timesplittingid !== $this->model->timesplitting) {
|
|
|
|
if (!\core_analytics\manager::is_valid($timesplittingid, '\core_analytics\local\time_splitting\base')) {
|
|
throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
|
|
}
|
|
|
|
if (substr($timesplittingid, 0, 1) !== '\\') {
|
|
throw new \moodle_exception('errorinvalidtimesplitting', 'analytics');
|
|
}
|
|
|
|
// Delete generated predictions before changing the model version.
|
|
$this->clear();
|
|
|
|
// It needs to be reset as the version changes.
|
|
$this->uniqueid = null;
|
|
|
|
$this->model->timesplitting = $timesplittingid;
|
|
$this->model->version = $now;
|
|
|
|
// Reset trained flag.
|
|
if (!$this->is_static()) {
|
|
$this->model->trained = 0;
|
|
}
|
|
} else if (empty($this->model->timesplitting)) {
|
|
// A valid timesplitting method needs to be supplied before a model can be enabled.
|
|
throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id);
|
|
|
|
}
|
|
|
|
// Purge pages with insights as this may change things.
|
|
if ($this->model->enabled != 1) {
|
|
$this->purge_insights_cache();
|
|
}
|
|
|
|
$this->model->enabled = 1;
|
|
$this->model->timemodified = $now;
|
|
$this->model->usermodified = $USER->id;
|
|
|
|
// We don't always update timemodified intentionally as we reserve it for target, indicators or timesplitting updates.
|
|
$DB->update_record('analytics_models', $this->model);
|
|
}
|
|
|
|
/**
|
|
* Is this a static model (as defined by the target)?.
|
|
*
|
|
* Static models are based on assumptions instead of in machine learning
|
|
* backends results.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function is_static() {
|
|
return (bool)$this->get_target()->based_on_assumptions();
|
|
}
|
|
|
|
/**
|
|
* Is this model enabled?
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function is_enabled() {
|
|
return (bool)$this->model->enabled;
|
|
}
|
|
|
|
/**
|
|
* Is this model already trained?
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function is_trained() {
|
|
// Models which targets are based on assumptions do not need training.
|
|
return (bool)$this->model->trained || $this->is_static();
|
|
}
|
|
|
|
/**
|
|
* Marks the model as trained
|
|
*
|
|
* @return void
|
|
*/
|
|
public function mark_as_trained() {
|
|
global $DB;
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
$this->model->trained = 1;
|
|
$DB->update_record('analytics_models', $this->model);
|
|
}
|
|
|
|
/**
|
|
* Get the contexts with predictions.
|
|
*
|
|
* @param bool $skiphidden Skip hidden predictions
|
|
* @return \stdClass[]
|
|
*/
|
|
public function get_predictions_contexts($skiphidden = true) {
|
|
global $DB, $USER;
|
|
|
|
$sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
|
|
JOIN {context} ctx ON ctx.id = ap.contextid
|
|
WHERE ap.modelid = :modelid";
|
|
$params = array('modelid' => $this->model->id);
|
|
|
|
if ($skiphidden) {
|
|
$sql .= " AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM {analytics_prediction_actions} apa
|
|
WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
|
|
)";
|
|
$params['userid'] = $USER->id;
|
|
$params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
|
|
$params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
|
|
}
|
|
|
|
return $DB->get_records_sql($sql, $params);
|
|
}
|
|
|
|
/**
|
|
* Has this model generated predictions?
|
|
*
|
|
* We don't check analytics_predictions table because targets have the ability to
|
|
* ignore some predicted values, if that is the case predictions are not even stored
|
|
* in db.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function any_prediction_obtained() {
|
|
global $DB;
|
|
return $DB->record_exists('analytics_predict_samples',
|
|
array('modelid' => $this->model->id, 'timesplitting' => $this->model->timesplitting));
|
|
}
|
|
|
|
/**
|
|
* Whether this model generates insights or not (defined by the model's target).
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function uses_insights() {
|
|
$target = $this->get_target();
|
|
return $target::uses_insights();
|
|
}
|
|
|
|
/**
|
|
* Whether predictions exist for this context.
|
|
*
|
|
* @param \context $context
|
|
* @return bool
|
|
*/
|
|
public function predictions_exist(\context $context) {
|
|
global $DB;
|
|
|
|
// Filters out previous predictions keeping only the last time range one.
|
|
$select = "modelid = :modelid AND contextid = :contextid";
|
|
$params = array('modelid' => $this->model->id, 'contextid' => $context->id);
|
|
return $DB->record_exists_select('analytics_predictions', $select, $params);
|
|
}
|
|
|
|
/**
|
|
* Gets the predictions for this context.
|
|
*
|
|
* @param \context $context
|
|
* @param bool $skiphidden Skip hidden predictions
|
|
* @param int $page The page of results to fetch. False for all results.
|
|
* @param int $perpage The max number of results to fetch. Ignored if $page is false.
|
|
* @return array($total, \core_analytics\prediction[])
|
|
*/
|
|
public function get_predictions(\context $context, $skiphidden = true, $page = false, $perpage = 100) {
|
|
global $DB, $USER;
|
|
|
|
\core_analytics\manager::check_can_list_insights($context);
|
|
|
|
// Filters out previous predictions keeping only the last time range one.
|
|
$sql = "SELECT ap.*
|
|
FROM {analytics_predictions} ap
|
|
JOIN (
|
|
SELECT sampleid, max(rangeindex) AS rangeindex
|
|
FROM {analytics_predictions}
|
|
WHERE modelid = :modelidsubap and contextid = :contextidsubap
|
|
GROUP BY sampleid
|
|
) apsub
|
|
ON ap.sampleid = apsub.sampleid AND ap.rangeindex = apsub.rangeindex
|
|
WHERE ap.modelid = :modelid and ap.contextid = :contextid";
|
|
|
|
$params = array('modelid' => $this->model->id, 'contextid' => $context->id,
|
|
'modelidsubap' => $this->model->id, 'contextidsubap' => $context->id);
|
|
|
|
if ($skiphidden) {
|
|
$sql .= " AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM {analytics_prediction_actions} apa
|
|
WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
|
|
)";
|
|
$params['userid'] = $USER->id;
|
|
$params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
|
|
$params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
|
|
}
|
|
|
|
$sql .= " ORDER BY ap.timecreated DESC";
|
|
if (!$predictions = $DB->get_records_sql($sql, $params)) {
|
|
return array();
|
|
}
|
|
|
|
// Get predicted samples' ids.
|
|
$sampleids = array_map(function($prediction) {
|
|
return $prediction->sampleid;
|
|
}, $predictions);
|
|
|
|
list($unused, $samplesdata) = $this->get_analyser()->get_samples($sampleids);
|
|
|
|
$current = 0;
|
|
|
|
if ($page !== false) {
|
|
$offset = $page * $perpage;
|
|
$limit = $offset + $perpage;
|
|
}
|
|
|
|
foreach ($predictions as $predictionid => $predictiondata) {
|
|
|
|
$sampleid = $predictiondata->sampleid;
|
|
|
|
// Filter out predictions which samples are not available anymore.
|
|
if (empty($samplesdata[$sampleid])) {
|
|
unset($predictions[$predictionid]);
|
|
continue;
|
|
}
|
|
|
|
// Return paginated dataset - we cannot paginate in the DB because we post filter the list.
|
|
if ($page === false || ($current >= $offset && $current < $limit)) {
|
|
// Replace \stdClass object by \core_analytics\prediction objects.
|
|
$prediction = new \core_analytics\prediction($predictiondata, $samplesdata[$sampleid]);
|
|
$predictions[$predictionid] = $prediction;
|
|
} else {
|
|
unset($predictions[$predictionid]);
|
|
}
|
|
|
|
$current++;
|
|
}
|
|
|
|
return [$current, $predictions];
|
|
}
|
|
|
|
/**
|
|
* Returns the sample data of a prediction.
|
|
*
|
|
* @param \stdClass $predictionobj
|
|
* @return array
|
|
*/
|
|
public function prediction_sample_data($predictionobj) {
|
|
|
|
list($unused, $samplesdata) = $this->get_analyser()->get_samples(array($predictionobj->sampleid));
|
|
|
|
if (empty($samplesdata[$predictionobj->sampleid])) {
|
|
throw new \moodle_exception('errorsamplenotavailable', 'analytics');
|
|
}
|
|
|
|
return $samplesdata[$predictionobj->sampleid];
|
|
}
|
|
|
|
/**
|
|
* Returns the description of a sample
|
|
*
|
|
* @param \core_analytics\prediction $prediction
|
|
* @return array 2 elements: list(string, \renderable)
|
|
*/
|
|
public function prediction_sample_description(\core_analytics\prediction $prediction) {
|
|
return $this->get_analyser()->sample_description($prediction->get_prediction_data()->sampleid,
|
|
$prediction->get_prediction_data()->contextid, $prediction->get_sample_data());
|
|
}
|
|
|
|
/**
|
|
* Returns the output directory for prediction processors.
|
|
*
|
|
* Directory structure as follows:
|
|
* - Evaluation runs:
|
|
* models/$model->id/$model->version/evaluation/$model->timesplitting
|
|
* - Training & prediction runs:
|
|
* models/$model->id/$model->version/execution
|
|
*
|
|
* @param array $subdirs
|
|
* @param bool $onlymodelid Preference over $subdirs
|
|
* @return string
|
|
*/
|
|
public function get_output_dir($subdirs = array(), $onlymodelid = false) {
|
|
global $CFG;
|
|
|
|
$subdirstr = '';
|
|
foreach ($subdirs as $subdir) {
|
|
$subdirstr .= DIRECTORY_SEPARATOR . $subdir;
|
|
}
|
|
|
|
$outputdir = get_config('analytics', 'modeloutputdir');
|
|
if (empty($outputdir)) {
|
|
// Apply default value.
|
|
$outputdir = rtrim($CFG->dataroot, '/') . DIRECTORY_SEPARATOR . 'models';
|
|
}
|
|
|
|
// Append model id.
|
|
$outputdir .= DIRECTORY_SEPARATOR . $this->model->id;
|
|
if (!$onlymodelid) {
|
|
// Append version + subdirs.
|
|
$outputdir .= DIRECTORY_SEPARATOR . $this->model->version . $subdirstr;
|
|
}
|
|
|
|
make_writable_directory($outputdir);
|
|
|
|
return $outputdir;
|
|
}
|
|
|
|
/**
|
|
* Returns a unique id for this model.
|
|
*
|
|
* This id should be unique for this site.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function get_unique_id() {
|
|
global $CFG;
|
|
|
|
if (!is_null($this->uniqueid)) {
|
|
return $this->uniqueid;
|
|
}
|
|
|
|
// Generate a unique id for this site, this model and this time splitting method, considering the last time
|
|
// that the model target and indicators were updated.
|
|
$ids = array($CFG->wwwroot, $CFG->prefix, $this->model->id, $this->model->version);
|
|
$this->uniqueid = sha1(implode('$$', $ids));
|
|
|
|
return $this->uniqueid;
|
|
}
|
|
|
|
/**
|
|
* Exports the model data for displaying it in a template.
|
|
*
|
|
* @param \renderer_base $output The renderer to use for exporting
|
|
* @return \stdClass
|
|
*/
|
|
public function export(\renderer_base $output) {
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
$data = clone $this->model;
|
|
|
|
$data->modelname = format_string($this->get_name());
|
|
$data->name = $this->inplace_editable_name()->export_for_template($output);
|
|
$data->target = $this->get_target()->get_name();
|
|
$data->targetclass = $this->get_target()->get_id();
|
|
|
|
if ($timesplitting = $this->get_time_splitting()) {
|
|
$data->timesplitting = $timesplitting->get_name();
|
|
}
|
|
|
|
$data->indicators = array();
|
|
foreach ($this->get_indicators() as $indicator) {
|
|
$data->indicators[] = $indicator->get_name();
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Exports the model data to a zip file.
|
|
*
|
|
* @param string $zipfilename
|
|
* @param bool $includeweights Include the model weights if available
|
|
* @return string Zip file path
|
|
*/
|
|
public function export_model(string $zipfilename, bool $includeweights = true) : string {
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
$modelconfig = new model_config($this);
|
|
return $modelconfig->export($zipfilename, $includeweights);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* Can this model be exported?
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function can_export_configuration() : bool {
|
|
|
|
if (empty($this->model->timesplitting)) {
|
|
return false;
|
|
}
|
|
if (!$this->get_indicators()) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->is_static()) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Returns the model logs data.
|
|
*
|
|
* @param int $limitfrom
|
|
* @param int $limitnum
|
|
* @return \stdClass[]
|
|
*/
|
|
public function get_logs($limitfrom = 0, $limitnum = 0) {
|
|
global $DB;
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
return $DB->get_records('analytics_models_log', array('modelid' => $this->get_id()), 'timecreated DESC', '*',
|
|
$limitfrom, $limitnum);
|
|
}
|
|
|
|
/**
|
|
* Merges all training data files into one and returns it.
|
|
*
|
|
* @return \stored_file|false
|
|
*/
|
|
public function get_training_data() {
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
$timesplittingid = $this->get_time_splitting()->get_id();
|
|
return \core_analytics\dataset_manager::export_training_data($this->get_id(), $timesplittingid);
|
|
}
|
|
|
|
/**
|
|
* Has the model been trained using data from this site?
|
|
*
|
|
* This method is useful to determine if a trained model can be evaluated as
|
|
* we can not use the same data for training and for evaluation.
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function trained_locally() : bool {
|
|
global $DB;
|
|
|
|
if (!$this->is_trained() || $this->is_static()) {
|
|
// Early exit.
|
|
return false;
|
|
}
|
|
|
|
if ($DB->record_exists('analytics_train_samples', ['modelid' => $this->model->id])) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Flag the provided file as used for training or prediction.
|
|
*
|
|
* @param \stored_file $file
|
|
* @param string $action
|
|
* @return void
|
|
*/
|
|
protected function flag_file_as_used(\stored_file $file, $action) {
|
|
global $DB;
|
|
|
|
$usedfile = new \stdClass();
|
|
$usedfile->modelid = $this->model->id;
|
|
$usedfile->fileid = $file->get_id();
|
|
$usedfile->action = $action;
|
|
$usedfile->time = time();
|
|
$DB->insert_record('analytics_used_files', $usedfile);
|
|
}
|
|
|
|
/**
|
|
* Log the evaluation results in the database.
|
|
*
|
|
* @param string $timesplittingid
|
|
* @param float $score
|
|
* @param string $dir
|
|
* @param array $info
|
|
* @param string $evaluationmode
|
|
* @return int The inserted log id
|
|
*/
|
|
protected function log_result($timesplittingid, $score, $dir = false, $info = false, $evaluationmode = 'configuration') {
|
|
global $DB, $USER;
|
|
|
|
$log = new \stdClass();
|
|
$log->modelid = $this->get_id();
|
|
$log->version = $this->model->version;
|
|
$log->evaluationmode = $evaluationmode;
|
|
$log->target = $this->model->target;
|
|
$log->indicators = $this->model->indicators;
|
|
$log->timesplitting = $timesplittingid;
|
|
$log->dir = $dir;
|
|
if ($info) {
|
|
// Ensure it is not an associative array.
|
|
$log->info = json_encode(array_values($info));
|
|
}
|
|
$log->score = $score;
|
|
$log->timecreated = time();
|
|
$log->usermodified = $USER->id;
|
|
|
|
return $DB->insert_record('analytics_models_log', $log);
|
|
}
|
|
|
|
/**
|
|
* Utility method to return indicator class names from a list of indicator objects
|
|
*
|
|
* @param \core_analytics\local\indicator\base[] $indicators
|
|
* @return string[]
|
|
*/
|
|
private static function indicator_classes($indicators) {
|
|
|
|
// What we want to check and store are the indicator classes not the keys.
|
|
$indicatorclasses = array();
|
|
foreach ($indicators as $indicator) {
|
|
if (!\core_analytics\manager::is_valid($indicator, '\core_analytics\local\indicator\base')) {
|
|
if (!is_object($indicator) && !is_scalar($indicator)) {
|
|
$indicator = strval($indicator);
|
|
} else if (is_object($indicator)) {
|
|
$indicator = '\\' . get_class($indicator);
|
|
}
|
|
throw new \moodle_exception('errorinvalidindicator', 'analytics', '', $indicator);
|
|
}
|
|
$indicatorclasses[] = $indicator->get_id();
|
|
}
|
|
|
|
return $indicatorclasses;
|
|
}
|
|
|
|
/**
|
|
* Clears the model training and prediction data.
|
|
*
|
|
* Executed after updating model critical elements like the time splitting method
|
|
* or the indicators.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function clear() {
|
|
global $DB, $USER;
|
|
|
|
\core_analytics\manager::check_can_manage_models();
|
|
|
|
// Delete current model version stored stuff.
|
|
$predictor = $this->get_predictions_processor(false);
|
|
if ($predictor->is_ready() !== true) {
|
|
$predictorname = \core_analytics\manager::get_predictions_processor_name($predictor);
|
|
debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
|
|
$this->model->id . ' could not be cleared.');
|
|
} else {
|
|
$predictor->clear_model($this->get_unique_id(), $this->get_output_dir());
|
|
}
|
|
|
|
$predictionids = $DB->get_fieldset_select('analytics_predictions', 'id', 'modelid = :modelid',
|
|
array('modelid' => $this->get_id()));
|
|
if ($predictionids) {
|
|
list($sql, $params) = $DB->get_in_or_equal($predictionids);
|
|
$DB->delete_records_select('analytics_prediction_actions', "predictionid $sql", $params);
|
|
}
|
|
|
|
$DB->delete_records('analytics_predictions', array('modelid' => $this->model->id));
|
|
$DB->delete_records('analytics_predict_samples', array('modelid' => $this->model->id));
|
|
$DB->delete_records('analytics_train_samples', array('modelid' => $this->model->id));
|
|
$DB->delete_records('analytics_used_files', array('modelid' => $this->model->id));
|
|
$DB->delete_records('analytics_used_analysables', array('modelid' => $this->model->id));
|
|
|
|
// Purge all generated files.
|
|
\core_analytics\dataset_manager::clear_model_files($this->model->id);
|
|
|
|
// We don't expect people to clear models regularly and the cost of filling the cache is
|
|
// 1 db read per context.
|
|
$this->purge_insights_cache();
|
|
|
|
if (!$this->is_static()) {
|
|
$this->model->trained = 0;
|
|
}
|
|
|
|
$this->model->timemodified = time();
|
|
$this->model->usermodified = $USER->id;
|
|
$DB->update_record('analytics_models', $this->model);
|
|
}
|
|
|
|
/**
|
|
* Returns the name of the model.
|
|
*
|
|
* By default, models use their target's name as their own name. They can have their explicit name, too. In which
|
|
* case, the explicit name is used instead of the default one.
|
|
*
|
|
* @return string|lang_string
|
|
*/
|
|
public function get_name() {
|
|
|
|
if (trim($this->model->name) === '') {
|
|
return $this->get_target()->get_name();
|
|
|
|
} else {
|
|
return $this->model->name;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renames the model to the given name.
|
|
*
|
|
* When given an empty string, the model falls back to using the associated target's name as its name.
|
|
*
|
|
* @param string $name The new name for the model, empty string for using the default name.
|
|
*/
|
|
public function rename(string $name) {
|
|
global $DB, $USER;
|
|
|
|
$this->model->name = $name;
|
|
$this->model->timemodified = time();
|
|
$this->model->usermodified = $USER->id;
|
|
|
|
$DB->update_record('analytics_models', $this->model);
|
|
}
|
|
|
|
/**
|
|
* Returns an inplace editable element with the model's name.
|
|
*
|
|
* @return \core\output\inplace_editable
|
|
*/
|
|
public function inplace_editable_name() {
|
|
|
|
$displayname = format_string($this->get_name());
|
|
|
|
return new \core\output\inplace_editable('core_analytics', 'modelname', $this->model->id,
|
|
has_capability('moodle/analytics:managemodels', \context_system::instance()), $displayname, $this->model->name);
|
|
}
|
|
|
|
/**
|
|
* Adds the id from {analytics_predictions} db table to the prediction \stdClass objects.
|
|
*
|
|
* @param \stdClass[] $predictionrecords
|
|
* @return \stdClass[] The prediction records including their ids in {analytics_predictions} db table.
|
|
*/
|
|
private function add_prediction_ids($predictionrecords) {
|
|
global $DB;
|
|
|
|
$firstprediction = reset($predictionrecords);
|
|
|
|
$contextids = array_map(function($predictionobj) {
|
|
return $predictionobj->contextid;
|
|
}, $predictionrecords);
|
|
list($contextsql, $contextparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED);
|
|
|
|
// We select the fields that will allow us to map ids to $predictionrecords. Given that we already filter by modelid
|
|
// we have enough with sampleid and rangeindex. The reason is that the sampleid relation to a site is N - 1.
|
|
$fields = 'id, sampleid, rangeindex';
|
|
|
|
// We include the contextid and the timecreated filter to reduce the number of records in $dbpredictions. We can not
|
|
// add as many OR conditions as records in $predictionrecords.
|
|
$sql = "SELECT $fields
|
|
FROM {analytics_predictions}
|
|
WHERE modelid = :modelid
|
|
AND contextid $contextsql
|
|
AND timecreated >= :firsttimecreated";
|
|
$params = $contextparams + ['modelid' => $this->model->id, 'firsttimecreated' => $firstprediction->timecreated];
|
|
$dbpredictions = $DB->get_recordset_sql($sql, $params);
|
|
foreach ($dbpredictions as $id => $dbprediction) {
|
|
// The append_rangeindex implementation is the same regardless of the time splitting method in use.
|
|
$uniqueid = $this->get_time_splitting()->append_rangeindex($dbprediction->sampleid, $dbprediction->rangeindex);
|
|
$predictionrecords[$uniqueid]->id = $dbprediction->id;
|
|
}
|
|
|
|
return $predictionrecords;
|
|
}
|
|
|
|
/**
|
|
* Purges the insights cache.
|
|
*/
|
|
private function purge_insights_cache() {
|
|
$cache = \cache::make('core', 'contextwithinsights');
|
|
$cache->purge();
|
|
}
|
|
|
|
/**
|
|
* Increases system memory and time limits.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function heavy_duty_mode() {
|
|
if (ini_get('memory_limit') != -1) {
|
|
raise_memory_limit(MEMORY_HUGE);
|
|
}
|
|
\core_php_time_limit::raise();
|
|
}
|
|
}
|
|
|