Ir a la última revisión | Autoría | Comparar con el anterior | Ultima modificación | Ver Log |
<?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;/*** @var \context[]*/protected $contexts = 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('notimesplitting' => 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');}$potentialtimesplittings = $this->get_potential_timesplittings();$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']);if (empty($potentialtimesplittings[$timesplitting->get_id()])) {throw new \moodle_exception('errorcannotusetimesplitting', 'analytics');}$timesplittings = array($timesplitting->get_id() => $timesplitting);} else {$timesplittingsforevaluation = \core_analytics\manager::get_time_splitting_methods_for_evaluation();// They both have the same objects, using $potentialtimesplittings as its items are sorted.$timesplittings = array_intersect_key($potentialtimesplittings, $timesplittingsforevaluation);}} 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);}/*** Returns the time-splitting methods that can be used by this model.** @return \core_analytics\local\time_splitting\base[]*/public function get_potential_timesplittings() {$timesplittings = \core_analytics\manager::get_all_time_splittings();uasort($timesplittings, function($a, $b) {return strcasecmp($a->get_name(), $b->get_name());});foreach ($timesplittings as $key => $timesplitting) {if (!$this->get_target()->can_use_timesplitting($timesplitting)) {unset($timesplittings[$key]);continue;}}return $timesplittings;}/*** 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* @param int[]|false $contextids List of context ids for this model. False to respect the current list of contexts.* @return void*/public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false,$contextids = 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 ($contextids === false) {$contextsstr = $this->model->contextids;} else if (!$contextids) {$contextsstr = null;} else {$contextsstr = json_encode($contextids);// Reset the internal cache.$this->contexts = null;}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->contextids = $contextsstr;$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), $this->get_unique_id());}$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($this->get_contexts());// 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($this->get_contexts());// 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($this->get_contexts());// 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($this->get_contexts());// 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);$samplesdata = $this->predictions_sample_data($predictionrecords);$samplesdata = $this->append_calculations_info($predictionrecords, $samplesdata);$predictions = array_map(function($predictionobj) use ($samplesdata) {$prediction = new \core_analytics\prediction($predictionobj, $samplesdata[$predictionobj->sampleid]);return $prediction;}, $predictionrecords);} else {$predictions = [];}$this->get_target()->generate_insight_notifications($this->model->id, $samplecontexts, $predictions);if ($this->get_target()->link_insights_report()) {// Update cache.foreach ($samplecontexts as $context) {\core_analytics\manager::cached_models_with_insights($context, $this->get_id());}}}/*** 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_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} apJOIN {context} ctx ON ctx.id = ap.contextidWHERE ap.modelid = :modelid";$params = array('modelid' => $this->model->id);if ($skiphidden) {$sql .= " AND NOT EXISTS (SELECT 1FROM {analytics_prediction_actions} apaWHERE apa.predictionid = ap.id AND apa.userid = :userid AND(apa.actionname = :fixed OR apa.actionname = :notuseful ORapa.actionname = :useful OR apa.actionname = :notapplicable ORapa.actionname = :incorrectlyflagged))";$params['userid'] = $USER->id;$params['fixed'] = \core_analytics\prediction::ACTION_FIXED;$params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;$params['useful'] = \core_analytics\prediction::ACTION_USEFUL;$params['notapplicable'] = \core_analytics\prediction::ACTION_NOT_APPLICABLE;$params['incorrectlyflagged'] = \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED;}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} apJOIN (SELECT sampleid, max(rangeindex) AS rangeindexFROM {analytics_predictions}WHERE modelid = :modelidsubap and contextid = :contextidsubapGROUP BY sampleid) apsubON ap.sampleid = apsub.sampleid AND ap.rangeindex = apsub.rangeindexWHERE 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 1FROM {analytics_prediction_actions} apaWHERE apa.predictionid = ap.id AND apa.userid = :userid AND(apa.actionname = :fixed OR apa.actionname = :notuseful ORapa.actionname = :useful OR apa.actionname = :notapplicable ORapa.actionname = :incorrectlyflagged))";$params['userid'] = $USER->id;$params['fixed'] = \core_analytics\prediction::ACTION_FIXED;$params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;$params['useful'] = \core_analytics\prediction::ACTION_USEFUL;$params['notapplicable'] = \core_analytics\prediction::ACTION_NOT_APPLICABLE;$params['incorrectlyflagged'] = \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED;}$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_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++;}if (empty($predictions)) {return array();}return [$current, $predictions];}/*** Returns the actions executed by users on the predictions.** @param \context|null $context* @return \moodle_recordset*/public function get_prediction_actions(?\context $context): \moodle_recordset {global $DB;$sql = "SELECT apa.id, apa.predictionid, apa.userid, apa.actionname, apa.timecreated,ap.contextid, ap.sampleid, ap.rangeindex, ap.prediction, ap.predictionscoreFROM {analytics_prediction_actions} apaJOIN {analytics_predictions} ap ON ap.id = apa.predictionidWHERE ap.modelid = :modelid";$params = ['modelid' => $this->model->id];if ($context) {$sql .= " AND ap.contextid = :contextid";$params['contextid'] = $context->id;}return $DB->get_recordset_sql($sql, $params);}/*** Returns the sample data of a prediction.** @param \stdClass $predictionobj* @return array*/public function prediction_sample_data($predictionobj) {list($unused, $samplesdata) = $this->get_samples(array($predictionobj->sampleid));if (empty($samplesdata[$predictionobj->sampleid])) {throw new \moodle_exception('errorsamplenotavailable', 'analytics');}return $samplesdata[$predictionobj->sampleid];}/*** Returns the samples data of the provided predictions.** @param \stdClass[] $predictionrecords* @return array*/public function predictions_sample_data(array $predictionrecords): array {$sampleids = [];foreach ($predictionrecords as $predictionobj) {$sampleids[] = $predictionobj->sampleid;}list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids);return $samplesdata;}/*** Appends the calculation info to the samples data.** @param \stdClass[] $predictionrecords* @param array $samplesdata* @return array*/public function append_calculations_info(array $predictionrecords, array $samplesdata): array {if ($extrainfo = calculation_info::pull_info($predictionrecords)) {foreach ($samplesdata as $sampleid => $data) {// The extra info come prefixed by extra: so we will not have overwrites here.$samplesdata[$sampleid] = $samplesdata[$sampleid] + $extrainfo[$sampleid];}}return $samplesdata;}/*** 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 default output directory for prediction processors** @return string*/public static function default_output_dir(): string {global $CFG;return $CFG->dataroot . DIRECTORY_SEPARATOR . 'models';}/*** 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) {$subdirstr = '';foreach ($subdirs as $subdir) {$subdirstr .= DIRECTORY_SEPARATOR . $subdir;}$outputdir = get_config('analytics', 'modeloutputdir');if (empty($outputdir)) {// Apply default value.$outputdir = self::default_output_dir();}// 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());}$DB->delete_records_select('analytics_prediction_actions', "predictionid IN(SELECT id FROM {analytics_predictions} WHERE modelid = :modelid)", ['modelid' => $this->get_id()]);$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);}/*** Returns true if the time-splitting method used by this model is invalid for this model.* @return bool*/public function invalid_timesplitting_selected(): bool {$currenttimesplitting = $this->model->timesplitting;if (empty($currenttimesplitting)) {// Not set is different from invalid. This function is used to identify invalid// time-splittings.return false;}$potentialtimesplittings = $this->get_potential_timesplittings();if ($currenttimesplitting && empty($potentialtimesplittings[$currenttimesplitting])) {return true;}return false;}/*** 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);// Limited to 30000 records as a middle point between the ~65000 params limit in pgsql and the size limit for mysql which// can be increased if required up to a reasonable point.$chunks = array_chunk($contextids, 30000);foreach ($chunks as $contextidschunk) {list($contextsql, $contextparams) = $DB->get_in_or_equal($contextidschunk, 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 $fieldsFROM {analytics_predictions}WHERE modelid = :modelidAND contextid $contextsqlAND 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;}/*** Wrapper around analyser's get_samples to skip DB's max-number-of-params exception.** @param array $sampleids* @return array*/public function get_samples(array $sampleids): array {if (empty($sampleids)) {throw new \coding_exception('No sample ids provided');}$chunksize = count($sampleids);// We start with just 1 chunk, if it is too large for the db we split the list of sampleids in 2 and we// try again. We repeat this process until the chunk is small enough for the db engine to process. The// >= has been added in case there are other \dml_read_exceptions unrelated to the max number of params.while (empty($done) && $chunksize >= 1) {$chunks = array_chunk($sampleids, $chunksize);$allsampleids = [];$allsamplesdata = [];foreach ($chunks as $index => $chunk) {try {list($chunksampleids, $chunksamplesdata) = $this->get_analyser()->get_samples($chunk);} catch (\dml_read_exception $e) {// Reduce the chunksize, we use floor() so the $chunksize is always less than the previous $chunksize value.$chunksize = floor($chunksize / 2);break;}// We can sum as these two arrays are indexed by sampleid and there are no collisions.$allsampleids = $allsampleids + $chunksampleids;$allsamplesdata = $allsamplesdata + $chunksamplesdata;if ($index === count($chunks) - 1) {// We successfully processed all the samples in all chunks, we are done.$done = true;}}}if (empty($done)) {if (!empty($e)) {// Throw the last exception we caught, the \dml_read_exception we have been catching is unrelated to the max number// of param's exception.throw new \dml_read_exception($e);} else {throw new \coding_exception('We should never reach this point, there is a bug in ' .'core_analytics\\model::get_samples\'s code');}}return [$allsampleids, $allsamplesdata];}/*** Contexts where this model should be active.** @return \context[] Empty array if there are no context restrictions.*/public function get_contexts() {if ($this->contexts !== null) {return $this->contexts;}if (!$this->model->contextids) {$this->contexts = [];return $this->contexts;}$contextids = json_decode($this->model->contextids);// We don't expect this list to be massive as contexts need to be selected manually using the edit model form.$this->contexts = array_map(function($contextid) {return \context::instance_by_id($contextid, IGNORE_MISSING);}, $contextids);return $this->contexts;}/*** 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();}}