first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
<?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/>.
/**
* Representation of a suggested action.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Representation of a suggested action.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class action {
/**
* @var Action type useful.
*/
const TYPE_POSITIVE = 'useful';
/**
* @var Action type notuseful.
*/
const TYPE_NEGATIVE = 'notuseful';
/**
* @var Action type neutral.
*/
const TYPE_NEUTRAL = 'neutral';
/**
* @var string
*/
protected $actionname = null;
/**
* @var \moodle_url
*/
protected $url = null;
/**
* @var \renderable
*/
protected $actionlink = null;
/**
* @var string
*/
protected $text = null;
/** @var string Store the action type. */
protected string $type = '';
/**
* Returns the action name.
*
* @return string
*/
public function get_action_name() {
return $this->actionname;
}
/**
* Returns the url to the action.
*
* @return \moodle_url
*/
public function get_url() {
return $this->url;
}
/**
* Returns the link to the action.
*
* @return \renderable
*/
public function get_action_link() {
return $this->actionlink;
}
/**
* Returns the action text.
* @return string
*/
public function get_text() {
return $this->text;
}
/**
* Sets the type of the action according to its positiveness.
*
* @throws \coding_exception
* @param string|false $type \core_analytics\action::TYPE_POSITIVE, TYPE_NEGATIVE or TYPE_NEUTRAL
*/
public function set_type($type = false) {
if (!$type) {
// Any non-standard action specified by a target is considered positive by default because that is what
// they are meant to be.
$type = self::TYPE_POSITIVE;
}
if ($type !== self::TYPE_POSITIVE && $type !== self::TYPE_NEUTRAL &&
$type !== self::TYPE_NEGATIVE) {
throw new \coding_exception('The provided type must be ' . self::TYPE_POSITIVE . ', ' . self::TYPE_NEUTRAL .
' or ' . self::TYPE_NEGATIVE);
}
$this->type = $type;
}
/**
* Returns the type of action.
*
* @return string The positiveness of the action (self::TYPE_POSITIVE, self::TYPE_NEGATIVE or self::TYPE_NEUTRAL)
*/
public function get_type() {
return $this->type;
}
}
@@ -0,0 +1,72 @@
<?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/>.
/**
* Extension to show an error message if the selected predictor is not available.
*
* @package core_analytics
* @copyright 2017 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();
require_once(__DIR__ . '/../../lib/adminlib.php');
/**
* Extension to show an error message if the selected predictor is not available.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class admin_setting_predictor extends \admin_setting_configselect {
/**
* Save a setting
*
* @param string $data
* @return string empty of error string
*/
public function write_setting($data) {
if (!$this->load_choices() or empty($this->choices)) {
return '';
}
if (!array_key_exists($data, $this->choices)) {
return '';
}
// Calling it here without checking if it is ready because we check it below and show it as a controlled case.
$selectedprocessor = \core_analytics\manager::get_predictions_processor($data, false);
$isready = $selectedprocessor->is_ready();
if ($isready !== true) {
return get_string('errorprocessornotready', 'analytics', $isready);
}
$currentvalue = get_config('analytics', 'predictionsprocessor');
if (!empty($currentvalue) && $currentvalue != str_replace('\\\\', '\\', $data)) {
// Clear all models data.
$models = \core_analytics\manager::get_all_models();
foreach ($models as $model) {
$model->clear();
}
}
return ($this->config_write($this->name, $data) ? '' : get_string('errorsetting', 'admin'));
}
}
+67
View File
@@ -0,0 +1,67 @@
<?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/>.
namespace core_analytics;
/**
* Any element analysers can analyse.
*
* @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
*/
interface analysable {
/**
* Max timestamp.
*/
const MAX_TIME = 9999999999;
/**
* The analysable unique identifier in the site.
*
* @return int.
*/
public function get_id();
/**
* The analysable human readable name
*
* @return string
*/
public function get_name();
/**
* The analysable context.
*
* @return \context
*/
public function get_context();
/**
* The start of the analysable if there is one.
*
* @return int|false
*/
public function get_start();
/**
* The end of the analysable if there is one.
*
* @return int|false
*/
public function get_end();
}
+959
View File
@@ -0,0 +1,959 @@
<?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/>.
/**
* Runs an analysis of the site.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Runs an analysis of the site.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class analysis {
/**
* @var \core_analytics\local\analyser\base
*/
private $analyser;
/**
* @var bool Whether to calculate the target or not in this run.
*/
private $includetarget;
/**
* @var \core_analytics\local\analysis\result
*/
private $result;
/**
* @var \core\lock\lock
*/
private $lock;
/**
* Constructor.
*
* @param \core_analytics\local\analyser\base $analyser
* @param bool $includetarget Whether to calculate the target or not.
* @param \core_analytics\local\analysis\result $result
*/
public function __construct(\core_analytics\local\analyser\base $analyser, bool $includetarget,
\core_analytics\local\analysis\result $result) {
$this->analyser = $analyser;
$this->includetarget = $includetarget;
$this->result = $result;
// We cache the first time analysables were analysed because time-splitting methods can depend on these info.
self::fill_firstanalyses_cache($this->analyser->get_modelid());
}
/**
* Runs the analysis.
*
* @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return null
*/
public function run(array $contexts = []) {
$options = $this->analyser->get_options();
// Time limit control.
$modeltimelimit = intval(get_config('analytics', 'modeltimelimit'));
if ($this->includetarget) {
$action = 'training';
} else {
$action = 'prediction';
}
$analysables = $this->analyser->get_analysables_iterator($action, $contexts);
$processedanalysables = $this->get_processed_analysables();
$inittime = microtime(true);
foreach ($analysables as $analysable) {
$processed = false;
if (!$analysable) {
continue;
}
$analysableresults = $this->process_analysable($analysable);
if ($analysableresults) {
$processed = $this->result->add_analysable_results($analysableresults);
if (!$processed) {
$errors = array();
foreach ($analysableresults as $timesplittingid => $result) {
$str = '';
if (count($analysableresults) > 1) {
$str .= $timesplittingid . ': ';
}
$str .= $result->message;
$errors[] = $str;
}
$a = new \stdClass();
$a->analysableid = $analysable->get_name();
$a->errors = implode(', ', $errors);
$this->analyser->add_log(get_string('analysablenotused', 'analytics', $a));
}
}
if (!$options['evaluation']) {
if (empty($processedanalysables[$analysable->get_id()]) ||
$this->analyser->get_target()->always_update_analysis_time() || $processed) {
// We store the list of processed analysables even if the target does not always_update_analysis_time(),
// what always_update_analysis_time controls is the update of the data.
$this->update_analysable_analysed_time($processedanalysables, $analysable->get_id());
}
// Apply time limit.
$timespent = microtime(true) - $inittime;
if ($modeltimelimit <= $timespent) {
break;
}
}
}
// Force GC to clean up the indicator instances used during the last iteration.
$this->analyser->instantiate_indicators();
}
/**
* Get analysables that have been already processed.
*
* @return \stdClass[]
*/
protected function get_processed_analysables(): array {
global $DB;
$params = array('modelid' => $this->analyser->get_modelid());
$params['action'] = ($this->includetarget) ? 'training' : 'prediction';
$select = 'modelid = :modelid and action = :action';
// Weird select fields ordering for performance (analysableid key matching, analysableid is also unique by modelid).
return $DB->get_records_select('analytics_used_analysables', $select,
$params, 'timeanalysed DESC', 'analysableid, modelid, action, firstanalysis, timeanalysed, id AS primarykey');
}
/**
* Processes an analysable
*
* This method returns the general analysable status, an array of files by time splitting method and
* an error message if there is any problem.
*
* @param \core_analytics\analysable $analysable
* @return \stdClass[] Results objects by time splitting method
*/
public function process_analysable(\core_analytics\analysable $analysable): array {
// Target instances scope is per-analysable (it can't be lower as calculations run once per
// analysable, not time splitting method nor time range).
$target = call_user_func(array($this->analyser->get_target(), 'instance'));
// We need to check that the analysable is valid for the target even if we don't include targets
// as we still need to discard invalid analysables for the target.
$isvalidresult = $target->is_valid_analysable($analysable, $this->includetarget);
if ($isvalidresult !== true) {
$a = new \stdClass();
$a->analysableid = $analysable->get_name();
$a->result = $isvalidresult;
$this->analyser->add_log(get_string('analysablenotvalidfortarget', 'analytics', $a));
return array();
}
// Process all provided time splitting methods.
$results = array();
foreach ($this->analyser->get_timesplittings() as $timesplitting) {
$cachedresult = $this->result->retrieve_cached_result($timesplitting, $analysable);
if ($cachedresult) {
$result = new \stdClass();
$result->result = $cachedresult;
$results[$timesplitting->get_id()] = $result;
continue;
}
$results[$timesplitting->get_id()] = $this->process_time_splitting($timesplitting, $analysable, $target);
}
return $results;
}
/**
* Processes the analysable samples using the provided time splitting method.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @param \core_analytics\local\target\base $target
* @return \stdClass Results object.
*/
protected function process_time_splitting(\core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\analysable $analysable, \core_analytics\local\target\base $target): \stdClass {
$options = $this->analyser->get_options();
$result = new \stdClass();
$timesplitting->set_modelid($this->analyser->get_modelid());
if (!$timesplitting->is_valid_analysable($analysable)) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('invalidanalysablefortimesplitting', 'analytics',
$timesplitting->get_name());
return $result;
}
$timesplitting->set_analysable($analysable);
if (CLI_SCRIPT && !PHPUNIT_TEST) {
mtrace('Analysing id "' . $analysable->get_id() . '" with "' . $timesplitting->get_name() .
'" time splitting method...');
}
// What is a sample is defined by the analyser, it can be an enrolment, a course, a user, a question
// attempt... it is on what we will base indicators calculations.
list($sampleids, $samplesdata) = $this->analyser->get_all_samples($analysable);
if (count($sampleids) === 0) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('nodata', 'analytics');
return $result;
}
if ($this->includetarget) {
// All ranges are used when we are calculating data for training.
$ranges = $timesplitting->get_training_ranges();
} else {
// The latest range that has not yet been used for prediction (it depends on the time range where we are right now).
$ranges = $timesplitting->get_most_recent_prediction_range();
}
// There is no need to keep track of the evaluated samples and ranges as we always evaluate the whole dataset.
if ($options['evaluation'] === false) {
if (empty($ranges)) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('noranges', 'analytics');
return $result;
}
// We skip all samples that are already part of a training dataset, even if they have not been used for prediction.
if (!$target::based_on_assumptions()) {
// Targets based on assumptions can not be trained.
$this->filter_out_train_samples($sampleids, $timesplitting);
}
if (count($sampleids) === 0) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('nonewdata', 'analytics');
return $result;
}
// Only when processing data for predictions.
if (!$this->includetarget) {
// We also filter out samples and ranges that have already been used for predictions.
$predictsamplesrecord = $this->filter_out_prediction_samples_and_ranges($sampleids, $ranges, $timesplitting);
}
if (count($sampleids) === 0) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('nonewdata', 'analytics');
return $result;
}
if (count($ranges) === 0) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('nonewranges', 'analytics');
return $result;
}
}
// Flag the model + analysable + timesplitting as being analysed (prevent concurrent executions).
if (!$this->init_analysable_analysis($timesplitting->get_id(), $analysable->get_id())) {
// If this model + analysable + timesplitting combination is being analysed we skip this process.
$result->status = \core_analytics\model::NO_DATASET;
$result->message = get_string('analysisinprogress', 'analytics');
return $result;
}
// Remove samples the target consider invalid.
try {
$target->add_sample_data($samplesdata);
$target->filter_out_invalid_samples($sampleids, $analysable, $this->includetarget);
} catch (\Throwable $e) {
$this->finish_analysable_analysis();
throw $e;
}
if (!$sampleids) {
$result->status = \core_analytics\model::NO_DATASET;
$result->message = get_string('novalidsamples', 'analytics');
$this->finish_analysable_analysis();
return $result;
}
try {
// Instantiate empty indicators to ensure that no garbage is dragged from previous analyses.
$indicators = $this->analyser->instantiate_indicators();
foreach ($indicators as $key => $indicator) {
// The analyser attaches the main entities the sample depends on and are provided to the
// indicator to calculate the sample.
$indicators[$key]->add_sample_data($samplesdata);
}
// Here we start the memory intensive process that will last until $data var is
// unset (until the method is finished basically).
$data = $this->calculate($timesplitting, $sampleids, $ranges, $target);
} catch (\Throwable $e) {
$this->finish_analysable_analysis();
throw $e;
}
if (!$data) {
$result->status = \core_analytics\model::ANALYSABLE_REJECTED_TIME_SPLITTING_METHOD;
$result->message = get_string('novaliddata', 'analytics');
$this->finish_analysable_analysis();
return $result;
}
try {
// No need to keep track of analysed stuff when evaluating.
if ($options['evaluation'] === false) {
// Save the samples that have been already analysed so they are not analysed again in future.
if ($this->includetarget) {
$this->save_train_samples($sampleids, $timesplitting);
} else {
// The variable $predictsamplesrecord will always be set as filter_out_prediction_samples_and_ranges
// will always be called before it (no evaluation mode and no includetarget).
$this->save_prediction_samples($sampleids, $ranges, $timesplitting, $predictsamplesrecord);
}
}
// We need to pass all the analysis data.
$formattedresult = $this->result->format_result($data, $target, $timesplitting, $analysable);
} catch (\Throwable $e) {
$this->finish_analysable_analysis();
throw $e;
}
if (!$formattedresult) {
$this->finish_analysable_analysis();
throw new \moodle_exception('errorcannotwritedataset', 'analytics');
}
$result->status = \core_analytics\model::OK;
$result->message = get_string('successfullyanalysed', 'analytics');
$result->result = $formattedresult;
// Flag the model + analysable + timesplitting as analysed.
$this->finish_analysable_analysis();
return $result;
}
/**
* Calculates indicators and targets.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param array $sampleids
* @param array $ranges
* @param \core_analytics\local\target\base $target
* @return array|null
*/
public function calculate(\core_analytics\local\time_splitting\base $timesplitting, array &$sampleids,
array $ranges, \core_analytics\local\target\base $target): ?array {
$calculatedtarget = null;
if ($this->includetarget) {
// We first calculate the target because analysable data may still be invalid or none
// of the analysable samples may be valid.
$calculatedtarget = $target->calculate($sampleids, $timesplitting->get_analysable());
// We remove samples we can not calculate their target.
$sampleids = array_filter($sampleids, function($sampleid) use ($calculatedtarget) {
if (is_null($calculatedtarget[$sampleid])) {
return false;
}
return true;
});
}
// No need to continue calculating if the target couldn't be calculated for any sample.
if (empty($sampleids)) {
return null;
}
$dataset = $this->calculate_indicators($timesplitting, $sampleids, $ranges);
if (empty($dataset)) {
return null;
}
// Now that we have the indicators in place we can add the time range indicators (and target if provided) to each of them.
$this->fill_dataset($timesplitting, $dataset, $calculatedtarget);
$this->add_context_metadata($timesplitting, $dataset, $target);
if (!PHPUNIT_TEST && CLI_SCRIPT) {
echo PHP_EOL;
}
return $dataset;
}
/**
* Calculates indicators.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param array $sampleids
* @param array $ranges
* @return array
*/
protected function calculate_indicators(\core_analytics\local\time_splitting\base $timesplitting, array $sampleids,
array $ranges): array {
global $DB;
$options = $this->analyser->get_options();
$dataset = array();
// Faster to run 1 db query per range.
$existingcalculations = array();
if ($timesplitting->cache_indicator_calculations()) {
foreach ($ranges as $rangeindex => $range) {
// Load existing calculations.
$existingcalculations[$rangeindex] = \core_analytics\manager::get_indicator_calculations(
$timesplitting->get_analysable(), $range['start'], $range['end'], $this->analyser->get_samples_origin());
}
}
// Here we store samples which calculations are not all null.
$notnulls = array();
// Fill the dataset samples with indicators data.
$newcalculations = array();
foreach ($this->analyser->get_indicators() as $indicator) {
// Hook to allow indicators to store analysable-dependant data.
$indicator->fill_per_analysable_caches($timesplitting->get_analysable());
// Per-range calculations.
foreach ($ranges as $rangeindex => $range) {
// Indicator instances are per-range.
$rangeindicator = clone $indicator;
$prevcalculations = array();
if (!empty($existingcalculations[$rangeindex][$rangeindicator->get_id()])) {
$prevcalculations = $existingcalculations[$rangeindex][$rangeindicator->get_id()];
}
// Calculate the indicator for each sample in this time range.
list($samplesfeatures, $newindicatorcalculations, $indicatornotnulls) = $rangeindicator->calculate($sampleids,
$this->analyser->get_samples_origin(), $range['start'], $range['end'], $prevcalculations);
// Associate the extra data generated by the indicator to this range index.
$rangeindicator->save_calculation_info($timesplitting, $rangeindex);
// Free memory ASAP.
unset($rangeindicator);
gc_collect_cycles();
gc_mem_caches();
// Copy the features data to the dataset.
foreach ($samplesfeatures as $analysersampleid => $features) {
$uniquesampleid = $timesplitting->append_rangeindex($analysersampleid, $rangeindex);
if (!isset($notnulls[$uniquesampleid]) && !empty($indicatornotnulls[$analysersampleid])) {
$notnulls[$uniquesampleid] = $uniquesampleid;
}
// Init the sample if it is still empty.
if (!isset($dataset[$uniquesampleid])) {
$dataset[$uniquesampleid] = array();
}
// Append the features indicator features at the end of the sample.
$dataset[$uniquesampleid] = array_merge($dataset[$uniquesampleid], $features);
}
if (!$options['evaluation'] && $timesplitting->cache_indicator_calculations()) {
$timecreated = time();
foreach ($newindicatorcalculations as $sampleid => $calculatedvalue) {
// Prepare the new calculations to be stored into DB.
$indcalc = new \stdClass();
$indcalc->contextid = $timesplitting->get_analysable()->get_context()->id;
$indcalc->starttime = $range['start'];
$indcalc->endtime = $range['end'];
$indcalc->sampleid = $sampleid;
$indcalc->sampleorigin = $this->analyser->get_samples_origin();
$indcalc->indicator = $indicator->get_id();
$indcalc->value = $calculatedvalue;
$indcalc->timecreated = $timecreated;
$newcalculations[] = $indcalc;
}
}
}
if (!$options['evaluation'] && $timesplitting->cache_indicator_calculations()) {
$batchsize = self::get_insert_batch_size();
if (count($newcalculations) > $batchsize) {
// We don't want newcalculations array to grow too much as we already keep the
// system memory busy storing $dataset contents.
// Insert from the beginning.
$remaining = array_splice($newcalculations, $batchsize);
// Sorry mssql and oracle, this will be slow.
$DB->insert_records('analytics_indicator_calc', $newcalculations);
$newcalculations = $remaining;
}
}
}
if (!$options['evaluation'] && $timesplitting->cache_indicator_calculations() && $newcalculations) {
// Insert the remaining records.
$DB->insert_records('analytics_indicator_calc', $newcalculations);
}
// Delete rows where all calculations are null.
// We still store the indicator calculation and we still store the sample id as
// processed so we don't have to process this sample again, but we exclude it
// from the dataset because it is not useful.
$nulls = array_diff_key($dataset, $notnulls);
foreach ($nulls as $uniqueid => $ignoredvalues) {
unset($dataset[$uniqueid]);
}
return $dataset;
}
/**
* Adds time range indicators and the target to each sample.
*
* This will identify the sample as belonging to a specific range.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param array $dataset
* @param array|null $calculatedtarget
* @return null
*/
protected function fill_dataset(\core_analytics\local\time_splitting\base $timesplitting,
array &$dataset, ?array $calculatedtarget = null) {
$nranges = count($timesplitting->get_distinct_ranges());
foreach ($dataset as $uniquesampleid => $unmodified) {
list($analysersampleid, $rangeindex) = $timesplitting->infer_sample_info($uniquesampleid);
// No need to add range features if this time splitting method only defines one time range.
if ($nranges > 1) {
// 1 column for each range.
$timeindicators = array_fill(0, $nranges, 0);
$timeindicators[$rangeindex] = 1;
$dataset[$uniquesampleid] = array_merge($timeindicators, $dataset[$uniquesampleid]);
}
if ($calculatedtarget) {
// Add this sampleid's calculated target and the end.
$dataset[$uniquesampleid][] = $calculatedtarget[$analysersampleid];
} else {
// Add this sampleid, it will be used to identify the prediction that comes back from
// the predictions processor.
array_unshift($dataset[$uniquesampleid], $uniquesampleid);
}
}
}
/**
* Updates the analysable analysis time.
*
* @param array $processedanalysables
* @param int $analysableid
* @return null
*/
protected function update_analysable_analysed_time(array $processedanalysables, int $analysableid) {
global $DB;
$now = time();
if (!empty($processedanalysables[$analysableid])) {
$obj = $processedanalysables[$analysableid];
$obj->id = $obj->primarykey;
unset($obj->primarykey);
$obj->timeanalysed = $now;
$DB->update_record('analytics_used_analysables', $obj);
} else {
$obj = new \stdClass();
$obj->modelid = $this->analyser->get_modelid();
$obj->action = ($this->includetarget) ? 'training' : 'prediction';
$obj->analysableid = $analysableid;
$obj->firstanalysis = $now;
$obj->timeanalysed = $now;
$obj->primarykey = $DB->insert_record('analytics_used_analysables', $obj);
// Update the cache just in case it is used in the same request.
$key = $this->analyser->get_modelid() . '_' . $analysableid;
$cache = \cache::make('core', 'modelfirstanalyses');
$cache->set($key, $now);
}
}
/**
* Fills a cache containing the first time each analysable in the provided model was analysed.
*
* @param int $modelid
* @param int|null $analysableid
* @return null
*/
public static function fill_firstanalyses_cache(int $modelid, ?int $analysableid = null) {
global $DB;
// Using composed keys instead of cache $identifiers because of MDL-65358.
$primarykey = $DB->sql_concat($modelid, "'_'", 'analysableid');
$sql = "SELECT $primarykey AS id, MIN(firstanalysis) AS firstanalysis
FROM {analytics_used_analysables} aua
WHERE modelid = :modelid";
$params = ['modelid' => $modelid];
if ($analysableid) {
$sql .= " AND analysableid = :analysableid";
$params['analysableid'] = $analysableid;
}
$sql .= " GROUP BY modelid, analysableid ORDER BY analysableid";
$firstanalyses = $DB->get_records_sql($sql, $params);
if ($firstanalyses) {
$cache = \cache::make('core', 'modelfirstanalyses');
$firstanalyses = array_map(function($record) {
return $record->firstanalysis;
}, $firstanalyses);
$cache->set_many($firstanalyses);
}
return $firstanalyses;
}
/**
* Adds dataset context info.
*
* The final dataset document will look like this:
* ----------------------------------------------------
* metadata1,metadata2,metadata3,.....
* value1, value2, value3,.....
*
* header1,header2,header3,header4,.....
* stud1value1,stud1value2,stud1value3,stud1value4,.....
* stud2value1,stud2value2,stud2value3,stud2value4,.....
* .....
* ----------------------------------------------------
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param array $dataset
* @param \core_analytics\local\target\base $target
* @return null
*/
protected function add_context_metadata(\core_analytics\local\time_splitting\base $timesplitting, array &$dataset,
\core_analytics\local\target\base $target) {
$headers = $this->get_headers($timesplitting, $target);
// This will also reset samples' dataset keys.
array_unshift($dataset, $headers);
}
/**
* Returns the headers for the csv file based on the indicators and the target.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\local\target\base $target
* @return string[]
*/
public function get_headers(\core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\local\target\base $target): array {
// 3rd column will contain the indicator ids.
$headers = array();
if (!$this->includetarget) {
// The first column is the sampleid.
$headers[] = 'sampleid';
}
// We always have 1 column for each time splitting method range, it does not depend on how
// many ranges we calculated.
$ranges = $timesplitting->get_distinct_ranges();
if (count($ranges) > 1) {
foreach ($ranges as $rangeindex) {
$headers[] = 'range/' . $rangeindex;
}
}
// Model indicators.
foreach ($this->analyser->get_indicators() as $indicator) {
$headers = array_merge($headers, $indicator::get_feature_headers());
}
// The target as well.
if ($this->includetarget) {
$headers[] = $target->get_id();
}
return $headers;
}
/**
* Filters out samples that have already been used for training.
*
* @param int[] $sampleids
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return null
*/
protected function filter_out_train_samples(array &$sampleids, \core_analytics\local\time_splitting\base $timesplitting) {
global $DB;
$params = array('modelid' => $this->analyser->get_modelid(), 'analysableid' => $timesplitting->get_analysable()->get_id(),
'timesplitting' => $timesplitting->get_id());
$trainingsamples = $DB->get_records('analytics_train_samples', $params);
// Skip each file trained samples.
foreach ($trainingsamples as $trainingfile) {
$usedsamples = json_decode($trainingfile->sampleids, true);
if (!empty($usedsamples)) {
// Reset $sampleids to $sampleids minus this file's $usedsamples.
$sampleids = array_diff_key($sampleids, $usedsamples);
}
}
}
/**
* Filters out samples that have already been used for prediction.
*
* @param int[] $sampleids
* @param array $ranges
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return \stdClass|null The analytics_predict_samples record or null
*/
protected function filter_out_prediction_samples_and_ranges(array &$sampleids, array &$ranges,
\core_analytics\local\time_splitting\base $timesplitting) {
if (count($ranges) > 1) {
throw new \coding_exception('$ranges argument should only contain one range');
}
$rangeindex = key($ranges);
$predictedrange = $this->get_predict_samples_record($timesplitting, $rangeindex);
if (!$predictedrange) {
// Nothing to filter out.
return null;
}
$predictedrange->sampleids = json_decode($predictedrange->sampleids, true);
$missingsamples = array_diff_key($sampleids, $predictedrange->sampleids);
if (count($missingsamples) === 0) {
// All samples already calculated.
unset($ranges[$rangeindex]);
return null;
}
// Replace the list of samples by the one excluding samples that already got predictions at this range.
$sampleids = $missingsamples;
return $predictedrange;
}
/**
* Returns a predict samples record.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param int $rangeindex
* @return \stdClass|false
*/
private function get_predict_samples_record(\core_analytics\local\time_splitting\base $timesplitting, int $rangeindex) {
global $DB;
$params = array('modelid' => $this->analyser->get_modelid(), 'analysableid' => $timesplitting->get_analysable()->get_id(),
'timesplitting' => $timesplitting->get_id(), 'rangeindex' => $rangeindex);
$predictedrange = $DB->get_record('analytics_predict_samples', $params);
return $predictedrange;
}
/**
* Saves samples that have just been used for training.
*
* @param int[] $sampleids
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return null
*/
protected function save_train_samples(array $sampleids, \core_analytics\local\time_splitting\base $timesplitting) {
global $DB;
$trainingsamples = new \stdClass();
$trainingsamples->modelid = $this->analyser->get_modelid();
$trainingsamples->analysableid = $timesplitting->get_analysable()->get_id();
$trainingsamples->timesplitting = $timesplitting->get_id();
$trainingsamples->sampleids = json_encode($sampleids);
$trainingsamples->timecreated = time();
$DB->insert_record('analytics_train_samples', $trainingsamples);
}
/**
* Saves samples that have just been used for prediction.
*
* @param int[] $sampleids
* @param array $ranges
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \stdClass|null $predictsamplesrecord The existing record or null if there is no record yet.
* @return null
*/
protected function save_prediction_samples(array $sampleids, array $ranges,
\core_analytics\local\time_splitting\base $timesplitting, ?\stdClass $predictsamplesrecord = null) {
global $DB;
if (count($ranges) > 1) {
throw new \coding_exception('$ranges argument should only contain one range');
}
$rangeindex = key($ranges);
if ($predictsamplesrecord) {
// Append the new samples used for prediction.
$predictsamplesrecord->sampleids = json_encode($predictsamplesrecord->sampleids + $sampleids);
$predictsamplesrecord->timemodified = time();
$DB->update_record('analytics_predict_samples', $predictsamplesrecord);
} else {
$predictsamplesrecord = (object)[
'modelid' => $this->analyser->get_modelid(),
'analysableid' => $timesplitting->get_analysable()->get_id(),
'timesplitting' => $timesplitting->get_id(), 'rangeindex' => $rangeindex
];
$predictsamplesrecord->sampleids = json_encode($sampleids);
$predictsamplesrecord->timecreated = time();
$predictsamplesrecord->timemodified = $predictsamplesrecord->timecreated;
$DB->insert_record('analytics_predict_samples', $predictsamplesrecord);
}
}
/**
* Flags the analysable element as in-analysis and stores a lock for it.
*
* @param string $timesplittingid
* @param int $analysableid
* @return bool Success or not
*/
private function init_analysable_analysis(string $timesplittingid, int $analysableid) {
// Do not include $this->includetarget as we don't want the same analysable to be analysed for training
// and prediction at the same time.
$lockkey = 'modelid:' . $this->analyser->get_modelid() . '-analysableid:' . $analysableid .
'-timesplitting:' . self::clean_time_splitting_id($timesplittingid);
// Large timeout as processes may be quite long.
$lockfactory = \core\lock\lock_config::get_lock_factory('core_analytics');
// If it is not ready in 10 secs skip this model + analysable + timesplittingmethod combination
// it will attempt it again during next cron run.
if (!$this->lock = $lockfactory->get_lock($lockkey, 10)) {
return false;
}
return true;
}
/**
* Remove all possibly problematic chars from the time splitting method id (id = its full class name).
*
* @param string $timesplittingid
* @return string
*/
public static function clean_time_splitting_id($timesplittingid) {
$timesplittingid = str_replace('\\', '-', $timesplittingid);
return clean_param($timesplittingid, PARAM_ALPHANUMEXT);
}
/**
* Mark the currently analysed analysable+timesplitting as analysed.
*
* @return null
*/
private function finish_analysable_analysis() {
$this->lock->release();
}
/**
* Returns the batch size used for insert_records.
*
* This method tries to find the best batch size without getting
* into dml internals. Maximum 1000 records to save memory.
*
* @return int
*/
private static function get_insert_batch_size(): int {
global $DB;
$dbconfig = $DB->export_dbconfig();
// 500 is pgsql default so using 1000 is fine, no other db driver uses a hardcoded value.
if (empty($dbconfig) || empty($dbconfig->dboptions) || empty($dbconfig->dboptions['bulkinsertsize'])) {
return 1000;
}
$bulkinsert = $dbconfig->dboptions['bulkinsertsize'];
if ($bulkinsert < 1000) {
return $bulkinsert;
}
while ($bulkinsert > 1000) {
$bulkinsert = round($bulkinsert / 2, 0);
}
return (int)$bulkinsert;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?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/>.
/**
* Representation of a suggested bulk action.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Representation of a suggested bulk action.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class bulk_action extends action {
/**
* Prediction action constructor.
*
* @param string $actionname They should match a-zA-Z_0-9-, as we apply a PARAM_ALPHANUMEXT filter
* @param \moodle_url $actionurl The final URL where the user should be forwarded.
* @param \pix_icon $icon Link icon
* @param string $text Link text
* @param bool $primary Primary button or secondary.
* @param array $attributes Link attributes
* @param string|false $type
* @return void
*/
public function __construct($actionname, \moodle_url $actionurl, \pix_icon $icon,
$text, $primary = false, $attributes = array(), $type = false) {
global $OUTPUT;
$this->actionname = $actionname;
$this->text = $text;
$this->set_type($type);
// We want to track how effective are our suggested actions, we pass users through a script that will log these actions.
$params = array('action' => $this->actionname, 'forwardurl' => $actionurl->out(false));
$this->url = new \moodle_url('/report/insights/action.php', $params);
$label = $OUTPUT->render($icon) . $this->text;
$this->actionlink = new \single_button($this->url, $label,
'get',
$primary ? \single_button::BUTTON_PRIMARY : \single_button::BUTTON_SECONDARY,
$attributes);
}
}
+326
View File
@@ -0,0 +1,326 @@
<?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/>.
/**
* Calculable dataset items abstract class.
*
* @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();
/**
* Calculable dataset items abstract class.
*
* @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
*/
abstract class calculable {
/**
* Neutral calculation outcome.
*/
const OUTCOME_NEUTRAL = 0;
/**
* Very positive calculation outcome.
*/
const OUTCOME_VERY_POSITIVE = 1;
/**
* Positive calculation outcome.
*/
const OUTCOME_OK = 2;
/**
* Negative calculation outcome.
*/
const OUTCOME_NEGATIVE = 3;
/**
* Very negative calculation outcome.
*/
const OUTCOME_VERY_NEGATIVE = 4;
/**
* @var array[]
*/
protected $sampledata = array();
/**
* @var \core_analytics\calculation_info|null
*/
protected $calculationinfo = null;
/**
* Returns a lang_string object representing the name for the indicator or target.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
abstract public static function get_name(): \lang_string;
/**
* The class id is the calculable class full qualified class name.
*
* @return string
*/
public function get_id() {
// Using get_class as get_component_classes_in_namespace returns double escaped fully qualified class names.
return '\\' . get_class($this);
}
/**
* add_sample_data
*
* @param array $data
* @return void
*/
public function add_sample_data($data) {
$this->sampledata = $this->array_merge_recursive_keep_keys($this->sampledata, $data);
}
/**
* clear_sample_data
*
* @return void
*/
public function clear_sample_data() {
$this->sampledata = array();
}
/**
* Returns the visible value of the calculated value.
*
* @param float $value
* @param string|false $subtype
* @return string
*/
public function get_display_value($value, $subtype = false) {
return $value;
}
/**
* Returns how good the calculated value is.
*
* Use one of \core_analytics\calculable::OUTCOME_* values.
*
* @param float $value
* @param string|false $subtype
* @return int
*/
abstract public function get_calculation_outcome($value, $subtype = false);
/**
* Retrieve the specified element associated to $sampleid.
*
* @param string $elementname
* @param int $sampleid
* @return \stdClass|false An \stdClass object or false if it can not be found.
*/
protected function retrieve($elementname, $sampleid) {
if (empty($this->sampledata[$sampleid]) || empty($this->sampledata[$sampleid][$elementname])) {
// We don't throw an exception because indicators should be able to
// try multiple tables until they find something they can use.
return false;
}
return $this->sampledata[$sampleid][$elementname];
}
/**
* Adds info related to the current calculation for later use when generating insights.
*
* Note that the data in $info array is reused across multiple samples, if you want to add data just for this
* sample you can use the sample id as key.
*
* Please, note that you should be careful with how much data you add here as it can kill the server memory.
*
* @param int $sampleid The sample id this data is associated with
* @param array $info The data. Indexed by an id unique across the site. E.g. an activity id.
* @return null
*/
final protected function add_shared_calculation_info(int $sampleid, array $info) {
if (is_null($this->calculationinfo)) {
// Lazy loading.
$this->calculationinfo = new \core_analytics\calculation_info();
}
$this->calculationinfo->add_shared($sampleid, $info);
}
/**
* Stores in MUC the previously added data and it associates it to the provided $calculable.
*
* Flagged as final as we don't want people to extend this, it is likely to be moved to \core_analytics\calculable
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param int $rangeindex
* @return null
*/
final public function save_calculation_info(\core_analytics\local\time_splitting\base $timesplitting, int $rangeindex) {
if (!is_null($this->calculationinfo)) {
$this->calculationinfo->save($this, $timesplitting, $rangeindex);
}
}
/**
* Returns the number of weeks a time range contains.
*
* Useful for calculations that depend on the time range duration. Note that it returns
* a float, rounding the float may lead to inaccurate results.
*
* @param int $starttime
* @param int $endtime
* @return float
*/
protected function get_time_range_weeks_number($starttime, $endtime) {
if ($endtime <= $starttime) {
throw new \coding_exception('End time timestamp should be greater than start time.');
}
$starttimedt = new \DateTime();
$starttimedt->setTimestamp($starttime);
$starttimedt->setTimezone(new \DateTimeZone('UTC'));
$endtimedt = new \DateTime();
$endtimedt->setTimestamp($endtime);
$endtimedt->setTimezone(new \DateTimeZone('UTC'));
$diff = $endtimedt->getTimestamp() - $starttimedt->getTimestamp();
return $diff / WEEKSECS;
}
/**
* Limits the calculated value to the minimum and maximum values.
*
* @param float $calculatedvalue
* @return float|null
*/
protected function limit_value($calculatedvalue) {
return max(min($calculatedvalue, static::get_max_value()), static::get_min_value());
}
/**
* Classifies the provided value into the provided range according to the ranges predicates.
*
* Use:
* - eq as 'equal'
* - ne as 'not equal'
* - lt as 'lower than'
* - le as 'lower or equal than'
* - gt as 'greater than'
* - ge as 'greater or equal than'
*
* @throws \coding_exception
* @param int|float $value
* @param array $ranges e.g. [ ['lt', 20], ['ge', 20] ]
* @return float
*/
protected function classify_value($value, $ranges) {
// To automatically return calculated values from min to max values.
$rangeweight = (static::get_max_value() - static::get_min_value()) / (count($ranges) - 1);
foreach ($ranges as $key => $range) {
$match = false;
if (count($range) != 2) {
throw new \coding_exception('classify_value() $ranges array param should contain 2 items, the predicate ' .
'e.g. greater (gt), lower or equal (le)... and the value.');
}
list($predicate, $rangevalue) = $range;
switch ($predicate) {
case 'eq':
if ($value == $rangevalue) {
$match = true;
}
break;
case 'ne':
if ($value != $rangevalue) {
$match = true;
}
break;
case 'lt':
if ($value < $rangevalue) {
$match = true;
}
break;
case 'le':
if ($value <= $rangevalue) {
$match = true;
}
break;
case 'gt':
if ($value > $rangevalue) {
$match = true;
}
break;
case 'ge':
if ($value >= $rangevalue) {
$match = true;
}
break;
default:
throw new \coding_exception('Unrecognised predicate ' . $predicate . '. Please use eq, ne, lt, le, ge or gt.');
}
// Calculate and return a linear calculated value for the provided value.
if ($match) {
return round(static::get_min_value() + ($rangeweight * $key), 2);
}
}
throw new \coding_exception('The provided value "' . $value . '" can not be fit into any of the provided ranges, you ' .
'should provide ranges for all possible values.');
}
/**
* Merges arrays recursively keeping the same keys the original arrays have.
*
* @link http://php.net/manual/es/function.array-merge-recursive.php#114818
* @return array
*/
private function array_merge_recursive_keep_keys() {
$arrays = func_get_args();
$base = array_shift($arrays);
foreach ($arrays as $array) {
reset($base);
foreach ($array as $key => $value) {
if (is_array($value) && !empty($base[$key]) && is_array($base[$key])) {
$base[$key] = $this->array_merge_recursive_keep_keys($base[$key], $value);
} else {
if (isset($base[$key]) && is_int($key)) {
$key++;
}
$base[$key] = $value;
}
}
}
return $base;
}
}
+184
View File
@@ -0,0 +1,184 @@
<?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/>.
/**
* Extra information generated during the analysis by calculable elements.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Extra information generated during the analysis by calculable elements.
*
* The main purpose of this request cache is to allow calculable elements to
* store data during their calculations for further use at a later stage efficiently.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class calculation_info {
/**
* @var array
*/
private $info = [];
/**
* @var mixed[]
*/
private $samplesinfo = [];
/**
* Adds info related to the current calculation for later use when generating insights.
*
* Note that the data in $info array is reused across multiple samples, if you want to add data just for this
* sample you can use the sample id as key.
*
* We store two different arrays so objects that appear multiple times for different samples
* appear just once in memory.
*
* @param int $sampleid The sample id this data is associated with
* @param array $info The data. Indexed by an id unique across the site. E.g. an activity id.
* @return null
*/
public function add_shared(int $sampleid, array $info) {
// We can safely overwrite the existing keys because the provided info is supposed to be unique
// for the indicator.
$this->info = $info + $this->info;
// We also need to store the association between the info provided and the sample.
$this->samplesinfo[$sampleid] = array_keys($info);
}
/**
* Stores in MUC the previously added data and it associates it to the provided $calculable.
*
* @param \core_analytics\calculable $calculable
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param int $rangeindex
* @return null
*/
public function save(\core_analytics\calculable $calculable, \core_analytics\local\time_splitting\base $timesplitting,
int $rangeindex) {
$calculableclass = get_class($calculable);
$cache = \cache::make('core', 'calculablesinfo');
foreach ($this->info as $key => $value) {
$datakey = self::get_data_key($calculableclass, $key);
// We do not overwrite existing data.
if (!$cache->has($datakey)) {
$cache->set($datakey, $value);
}
}
foreach ($this->samplesinfo as $sampleid => $infokeys) {
$uniquesampleid = $timesplitting->append_rangeindex($sampleid, $rangeindex);
$samplekey = self::get_sample_key($uniquesampleid);
// Update the cached data adding the new indicator data.
$cacheddata = $cache->get($samplekey) ?: [];
$cacheddata[$calculableclass] = $infokeys;
$cache->set($samplekey, $cacheddata);
}
// Empty the in-memory arrays now that it is in the cache.
$this->info = [];
$this->samplesinfo = [];
}
/**
* Pulls the info related to the provided records out from the cache.
*
* Note that this function purges 'calculablesinfo' cache.
*
* @param \stdClass[] $predictionrecords
* @return array|false
*/
public static function pull_info(array $predictionrecords) {
$cache = \cache::make('core', 'calculablesinfo');
foreach ($predictionrecords as $uniquesampleid => $predictionrecord) {
$sampleid = $predictionrecord->sampleid;
$sampleinfo = $cache->get(self::get_sample_key($uniquesampleid));
// MUC returns (or should return) copies of the data and we want a single copy of it so
// we store the data here and reference it from each sample. Samples data should not be
// changed afterwards.
$data = [];
if ($sampleinfo) {
foreach ($sampleinfo as $calculableclass => $infokeys) {
foreach ($infokeys as $infokey) {
// We don't need to retrieve data back from MUC if we already have it.
if (!isset($data[$calculableclass][$infokey])) {
$datakey = self::get_data_key($calculableclass, $infokey);
$data[$calculableclass][$infokey] = $cache->get($datakey);
}
$samplesdatakey = $calculableclass . ':extradata';
$samplesdata[$sampleid][$samplesdatakey][$infokey] = & $data[$calculableclass][$infokey];
}
}
}
}
// Free memory ASAP. We can replace the purge call by a delete_many if we are interested on allowing
// multiple calls to pull_info passing in different $sampleids.
$cache->purge();
if (empty($samplesdata)) {
return false;
}
return $samplesdata;
}
/**
* Gets the key used to store data.
*
* @param string $calculableclass
* @param string|int $key
* @return string
*/
private static function get_data_key(string $calculableclass, $key): string {
return 'data:' . $calculableclass . ':' . $key;
}
/**
* Gets the key used to store samples.
*
* @param string $uniquesampleid
* @return string
*/
private static function get_sample_key(string $uniquesampleid): string {
return 'sample:' . $uniquesampleid;
}
}
+71
View File
@@ -0,0 +1,71 @@
<?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/>.
/**
* Classifier interface.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Classifier interface.
*
* @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
*/
interface classifier extends predictor {
/**
* Train this processor classification model using the provided supervised learning dataset.
*
* @param string $uniqueid
* @param \stored_file $dataset
* @param string $outputdir
* @return \stdClass
*/
public function train_classification($uniqueid, \stored_file $dataset, $outputdir);
/**
* Classifies the provided dataset samples.
*
* @param string $uniqueid
* @param \stored_file $dataset
* @param string $outputdir
* @return \stdClass
*/
public function classify($uniqueid, \stored_file $dataset, $outputdir);
/**
* Evaluates this processor classification model using the provided supervised learning dataset.
*
* @param string $uniqueid
* @param float $maxdeviation
* @param int $niterations
* @param \stored_file $dataset
* @param string $outputdir
* @param string $trainedmodeldir
* @return \stdClass
*/
public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
$outputdir, $trainedmodeldir);
}
+628
View File
@@ -0,0 +1,628 @@
<?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/>.
/**
* Moodle course analysable
*
* @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();
require_once($CFG->dirroot . '/course/lib.php');
require_once($CFG->dirroot . '/lib/gradelib.php');
require_once($CFG->dirroot . '/lib/enrollib.php');
/**
* Moodle course analysable
*
* @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 course implements \core_analytics\analysable {
/**
* @var bool Has this course data been already loaded.
*/
protected $loaded = false;
/**
* @var int $cachedid self::$cachedinstance analysable id.
*/
protected static $cachedid = 0;
/**
* @var \core_analytics\course $cachedinstance
*/
protected static $cachedinstance = null;
/**
* Course object
*
* @var \stdClass
*/
protected $course = null;
/**
* The course context.
*
* @var \context_course
*/
protected $coursecontext = null;
/**
* The course activities organized by activity type.
*
* @var array
*/
protected $courseactivities = array();
/**
* Course start time.
*
* @var int
*/
protected $starttime = null;
/**
* Has the course already started?
*
* @var bool
*/
protected $started = null;
/**
* Course end time.
*
* @var int
*/
protected $endtime = null;
/**
* Is the course finished?
*
* @var bool
*/
protected $finished = null;
/**
* Course students ids.
*
* @var int[]
*/
protected $studentids = [];
/**
* Course teachers ids
*
* @var int[]
*/
protected $teacherids = [];
/**
* Cached copy of the total number of logs in the course.
*
* @var int
*/
protected $ntotallogs = null;
/** @var int Store current Unix timestamp. */
protected int $now = 0;
/**
* Course manager constructor.
*
* Use self::instance() instead to get cached copies of the course. Instances obtained
* through this constructor will not be cached.
*
* @param int|\stdClass $course Course id or mdl_course record
* @param \context|null $context
* @return void
*/
public function __construct($course, ?\context $context = null) {
if (is_scalar($course)) {
$this->course = new \stdClass();
$this->course->id = $course;
} else {
$this->course = $course;
}
if (!is_null($context)) {
$this->coursecontext = $context;
}
}
/**
* Returns an analytics course instance.
*
* Lazy load of course data, students and teachers.
*
* @param int|\stdClass $course Course object or course id
* @param \context|null $context
* @return \core_analytics\course
*/
public static function instance($course, ?\context $context = null) {
$courseid = $course;
if (!is_scalar($courseid)) {
$courseid = $course->id;
}
if (self::$cachedid === $courseid) {
return self::$cachedinstance;
}
$cachedinstance = new \core_analytics\course($course, $context);
self::$cachedinstance = $cachedinstance;
self::$cachedid = (int)$courseid;
return self::$cachedinstance;
}
/**
* get_id
*
* @return int
*/
public function get_id() {
return $this->course->id;
}
/**
* Loads the analytics course object.
*
* @return void
*/
protected function load() {
// The instance constructor could be already loaded with the full course object. Using shortname
// because it is a required course field.
if (empty($this->course->shortname)) {
$this->course = get_course($this->course->id);
}
$this->coursecontext = $this->get_context();
$this->now = time();
// Get the course users, including users assigned to student and teacher roles at an higher context.
$cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_analytics', 'rolearchetypes');
// Flag the instance as loaded.
$this->loaded = true;
if (!$studentroles = $cache->get('student')) {
$studentroles = array_keys(get_archetype_roles('student'));
$cache->set('student', $studentroles);
}
$this->studentids = $this->get_user_ids($studentroles);
if (!$teacherroles = $cache->get('teacher')) {
$teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
$cache->set('teacher', $teacherroles);
}
$this->teacherids = $this->get_user_ids($teacherroles);
}
/**
* The course short name
*
* @return string
*/
public function get_name() {
return format_string($this->get_course_data()->shortname, true, array('context' => $this->get_context()));
}
/**
* get_context
*
* @return \context
*/
public function get_context() {
if ($this->coursecontext === null) {
$this->coursecontext = \context_course::instance($this->course->id);
}
return $this->coursecontext;
}
/**
* Get the course start timestamp.
*
* @return int Timestamp or 0 if has not started yet.
*/
public function get_start() {
if ($this->starttime !== null) {
return $this->starttime;
}
// The field always exist but may have no valid if the course is created through a sync process.
if (!empty($this->get_course_data()->startdate)) {
$this->starttime = (int)$this->get_course_data()->startdate;
} else {
$this->starttime = 0;
}
return $this->starttime;
}
/**
* Guesses the start of the course based on students' activity and enrolment start dates.
*
* @return int
*/
public function guess_start() {
global $DB;
if (!$this->get_total_logs()) {
// Can't guess.
return 0;
}
if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
return 0;
}
// We first try to find current course student logs.
$firstlogs = array();
foreach ($this->get_students() as $studentid) {
// Grrr, we are limited by logging API, we could do this easily with a
// select min(timecreated) from xx where courseid = yy group by userid.
// Filters based on the premise that more than 90% of people will be using
// standard logstore, which contains a userid, contextlevel, contextinstanceid index.
$select = "userid = :userid AND contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid";
$params = array('userid' => $studentid, 'contextlevel' => CONTEXT_COURSE, 'contextinstanceid' => $this->get_id());
$events = $logstore->get_events_select($select, $params, 'timecreated ASC', 0, 1);
if ($events) {
$event = reset($events);
$firstlogs[] = $event->timecreated;
}
}
if (empty($firstlogs)) {
// Can't guess if no student accesses.
return 0;
}
sort($firstlogs);
$firstlogsmedian = $this->median($firstlogs);
$studentenrolments = enrol_get_course_users($this->get_id(), $this->get_students());
if (empty($studentenrolments)) {
return 0;
}
$enrolstart = array();
foreach ($studentenrolments as $studentenrolment) {
$enrolstart[] = ($studentenrolment->uetimestart) ? $studentenrolment->uetimestart : $studentenrolment->uetimecreated;
}
sort($enrolstart);
$enrolstartmedian = $this->median($enrolstart);
return intval(($enrolstartmedian + $firstlogsmedian) / 2);
}
/**
* Get the course end timestamp.
*
* @return int Timestamp or 0 if time end was not set.
*/
public function get_end() {
global $DB;
if ($this->endtime !== null) {
return $this->endtime;
}
// The enddate field is only available from Moodle 3.2 (MDL-22078).
if (!empty($this->get_course_data()->enddate)) {
$this->endtime = (int)$this->get_course_data()->enddate;
return $this->endtime;
}
return 0;
}
/**
* Get the course end timestamp.
*
* @return int Timestamp, \core_analytics\analysable::MAX_TIME if we don't know but ongoing and 0 if we can not work it out.
*/
public function guess_end() {
global $DB;
if ($this->get_total_logs() === 0) {
// No way to guess if there are no logs.
$this->endtime = 0;
return $this->endtime;
}
list($filterselect, $filterparams) = $this->course_students_query_filter('ula');
// Consider the course open if there are still student accesses.
$monthsago = time() - (WEEKSECS * 4 * 2);
$select = $filterselect . ' AND timeaccess > :timeaccess';
$params = $filterparams + array('timeaccess' => $monthsago);
$sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula
JOIN {enrol} e ON e.courseid = ula.courseid
JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
WHERE $select";
if ($records = $DB->get_records_sql($sql, $params)) {
return 0;
}
$sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula
JOIN {enrol} e ON e.courseid = ula.courseid
JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
WHERE $filterselect AND ula.timeaccess != 0
ORDER BY timeaccess DESC";
$studentlastaccesses = $DB->get_fieldset_sql($sql, $filterparams);
if (empty($studentlastaccesses)) {
return 0;
}
sort($studentlastaccesses);
return $this->median($studentlastaccesses);
}
/**
* Returns a course plain object.
*
* @return \stdClass
*/
public function get_course_data() {
if (!$this->loaded) {
$this->load();
}
return $this->course;
}
/**
* Has the course started?
*
* @return bool
*/
public function was_started() {
if ($this->started === null) {
if ($this->get_start() === 0 || $this->now < $this->get_start()) {
// Not yet started.
$this->started = false;
} else {
$this->started = true;
}
}
return $this->started;
}
/**
* Has the course finished?
*
* @return bool
*/
public function is_finished() {
if ($this->finished === null) {
$endtime = $this->get_end();
if ($endtime === 0 || $this->now < $endtime) {
// It is not yet finished or no idea when it finishes.
$this->finished = false;
} else {
$this->finished = true;
}
}
return $this->finished;
}
/**
* Returns a list of user ids matching the specified roles in this course.
*
* @param array $roleids
* @return array
*/
public function get_user_ids($roleids) {
// We need to index by ra.id as a user may have more than 1 $roles role.
$records = get_role_users($roleids, $this->get_context(), true, 'ra.id, u.id AS userid, r.id AS roleid', 'ra.id ASC');
// If a user have more than 1 $roles role array_combine will discard the duplicate.
$callable = array($this, 'filter_user_id');
$userids = array_values(array_map($callable, $records));
return array_combine($userids, $userids);
}
/**
* Returns the course students.
*
* @return int[]
*/
public function get_students() {
if (!$this->loaded) {
$this->load();
}
return $this->studentids;
}
/**
* Returns the total number of student logs in the course
*
* @return int
*/
public function get_total_logs() {
global $DB;
// No logs if no students.
if (empty($this->get_students())) {
return 0;
}
if ($this->ntotallogs === null) {
list($filterselect, $filterparams) = $this->course_students_query_filter();
if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
$this->ntotallogs = 0;
} else {
$this->ntotallogs = $logstore->get_events_select_count($filterselect, $filterparams);
}
}
return $this->ntotallogs;
}
/**
* Returns all the activities of the provided type the course has.
*
* @param string $activitytype
* @return array
*/
public function get_all_activities($activitytype) {
// Using is set because we set it to false if there are no activities.
if (!isset($this->courseactivities[$activitytype])) {
$modinfo = get_fast_modinfo($this->get_course_data(), -1);
$instances = $modinfo->get_instances_of($activitytype);
if ($instances) {
$this->courseactivities[$activitytype] = array();
foreach ($instances as $instance) {
// By context.
$this->courseactivities[$activitytype][$instance->context->id] = $instance;
}
} else {
$this->courseactivities[$activitytype] = false;
}
}
return $this->courseactivities[$activitytype];
}
/**
* Returns the course students grades.
*
* @param array $courseactivities
* @return array
*/
public function get_student_grades($courseactivities) {
if (empty($courseactivities)) {
return array();
}
$grades = array();
foreach ($courseactivities as $contextid => $instance) {
$gradesinfo = grade_get_grades($this->course->id, 'mod', $instance->modname, $instance->instance, $this->studentids);
// Sort them by activity context and user.
if ($gradesinfo && $gradesinfo->items) {
foreach ($gradesinfo->items as $gradeitem) {
foreach ($gradeitem->grades as $userid => $grade) {
if (empty($grades[$contextid][$userid])) {
// Initialise it as array because a single activity can have multiple grade items (e.g. workshop).
$grades[$contextid][$userid] = array();
}
$grades[$contextid][$userid][$gradeitem->id] = $grade;
}
}
}
}
return $grades;
}
/**
* Used by get_user_ids to extract the user id.
*
* @param \stdClass $record
* @return int The user id.
*/
protected function filter_user_id($record) {
return $record->userid;
}
/**
* Returns the average time between 2 timestamps.
*
* @param int $start
* @param int $end
* @return array [starttime, averagetime, endtime]
*/
protected function update_loop_times($start, $end) {
$avg = intval(($start + $end) / 2);
return array($start, $avg, $end);
}
/**
* Returns the query and params used to filter the logstore by this course students.
*
* @param string $prefix
* @return array
*/
protected function course_students_query_filter($prefix = false) {
global $DB;
if ($prefix) {
$prefix = $prefix . '.';
}
// Check the amount of student logs in the 4 previous weeks.
list($studentssql, $studentsparams) = $DB->get_in_or_equal($this->get_students(), SQL_PARAMS_NAMED);
$filterselect = $prefix . 'courseid = :courseid AND ' . $prefix . 'userid ' . $studentssql;
$filterparams = array('courseid' => $this->course->id) + $studentsparams;
return array($filterselect, $filterparams);
}
/**
* Calculate median
*
* Keys are ignored.
*
* @param int[]|float[] $values Sorted array of values
* @return int
*/
protected function median($values) {
$count = count($values);
if ($count === 1) {
return reset($values);
}
$middlevalue = (int)floor(($count - 1) / 2);
if ($count % 2) {
// Odd number, middle is the median.
$median = $values[$middlevalue];
} else {
// Even number, calculate avg of 2 medians.
$low = $values[$middlevalue];
$high = $values[$middlevalue + 1];
$median = (($low + $high) / 2);
}
return intval($median);
}
}
+457
View File
@@ -0,0 +1,457 @@
<?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/>.
/**
* Datasets manager.
*
* @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();
/**
* Datasets manager.
*
* @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 dataset_manager {
/**
* File area for labelled datasets.
*/
const LABELLED_FILEAREA = 'labelled';
/**
* File area for unlabelled datasets.
*/
const UNLABELLED_FILEAREA = 'unlabelled';
/**
* File area for exported datasets.
*/
const EXPORT_FILEAREA = 'export';
/**
* Evaluation file file name.
*/
const EVALUATION_FILENAME = 'evaluation.csv';
/**
* The model id.
*
* @var int
*/
protected $modelid;
/**
* Range processor in use.
*
* @var string
*/
protected $timesplittingid;
/**
* @var int
*/
protected $analysableid;
/**
* Whether this is a dataset for evaluation or not.
*
* @var bool
*/
protected $evaluation;
/**
* The dataset filearea. Must be one of the self::*_FILEAREA options.
*
* @var string
*/
protected $filearea;
/**
* Constructor method.
*
* @throws \coding_exception
* @param int $modelid
* @param int $analysableid
* @param string $timesplittingid
* @param string $filearea
* @param bool $evaluation
* @return void
*/
public function __construct($modelid, $analysableid, $timesplittingid, $filearea, $evaluation = false) {
if ($filearea !== self::EXPORT_FILEAREA && $filearea !== self::LABELLED_FILEAREA &&
$filearea !== self::UNLABELLED_FILEAREA) {
throw new \coding_exception('Invalid provided filearea');
}
$this->modelid = $modelid;
$this->analysableid = $analysableid;
$this->timesplittingid = $timesplittingid;
$this->filearea = $filearea;
$this->evaluation = $evaluation;
}
/**
* Store the dataset in the internal file system.
*
* @param array $data
* @return \stored_file
*/
public function store($data) {
// Delete previous file if it exists.
$fs = get_file_storage();
$filerecord = [
'component' => 'analytics',
'filearea' => $this->filearea,
'itemid' => $this->modelid,
'contextid' => \context_system::instance()->id,
'filepath' => '/analysable/' . $this->analysableid . '/' .
\core_analytics\analysis::clean_time_splitting_id($this->timesplittingid) . '/',
'filename' => self::get_filename($this->evaluation)
];
// Delete previous and old (we already checked that previous copies are not recent) evaluation files for this analysable.
if ($this->evaluation) {
$select = " = {$filerecord['itemid']} AND filepath = :filepath";
$fs->delete_area_files_select($filerecord['contextid'], $filerecord['component'], $filerecord['filearea'],
$select, array('filepath' => $filerecord['filepath']));
}
// Write all this stuff to a tmp file.
$filepath = make_request_directory() . DIRECTORY_SEPARATOR . $filerecord['filename'];
$fh = fopen($filepath, 'w+');
if (!$fh) {
return false;
}
foreach ($data as $line) {
fputcsv($fh, $line);
}
fclose($fh);
return $fs->create_file_from_pathname($filerecord, $filepath);
}
/**
* Returns the previous evaluation file.
*
* Important to note that this is per modelid + timesplittingid, when dealing with multiple
* analysables this is the merged file. Do not confuse with self::get_evaluation_analysable_file
*
* @param int $modelid
* @param string $timesplittingid
* @return \stored_file
*/
public static function get_previous_evaluation_file($modelid, $timesplittingid) {
$fs = get_file_storage();
// Evaluation data is always labelled.
$filepath = '/timesplitting/' . \core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/';
return $fs->get_file(\context_system::instance()->id, 'analytics', self::LABELLED_FILEAREA, $modelid,
$filepath, self::EVALUATION_FILENAME);
}
/**
* Gets the list of files that couldn't be previously used for training and prediction.
*
* @param int $modelid
* @param bool $includetarget
* @param string[] $timesplittingids
* @return null
*/
public static function get_pending_files($modelid, $includetarget, $timesplittingids) {
global $DB;
$fs = get_file_storage();
if ($includetarget) {
$filearea = self::LABELLED_FILEAREA;
$usedfileaction = 'trained';
} else {
$filearea = self::UNLABELLED_FILEAREA;
$usedfileaction = 'predicted';
}
$select = 'modelid = :modelid AND action = :action';
$params = array('modelid' => $modelid, 'action' => $usedfileaction);
$usedfileids = $DB->get_fieldset_select('analytics_used_files', 'fileid', $select, $params);
// Very likely that we will only have 1 time splitting method here.
$filesbytimesplitting = array();
foreach ($timesplittingids as $timesplittingid) {
$filepath = '/timesplitting/' . \core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/';
$files = $fs->get_directory_files(\context_system::instance()->id, 'analytics', $filearea, $modelid, $filepath);
foreach ($files as $file) {
// Discard evaluation files.
if ($file->get_filename() === self::EVALUATION_FILENAME) {
continue;
}
// No dirs.
if ($file->is_directory()) {
continue;
}
// Already used for training.
if (in_array($file->get_id(), $usedfileids)) {
continue;
}
$filesbytimesplitting[$timesplittingid][] = $file;
}
}
return $filesbytimesplitting;
}
/**
* Deletes previous evaluation files of this model.
*
* @param int $modelid
* @param string $timesplittingid
* @return bool
*/
public static function delete_previous_evaluation_file($modelid, $timesplittingid) {
if ($file = self::get_previous_evaluation_file($modelid, $timesplittingid)) {
$file->delete();
return true;
}
return false;
}
/**
* Returns this (model + analysable + time splitting) file.
*
* @param int $modelid
* @param int $analysableid
* @param string $timesplittingid
* @return \stored_file
*/
public static function get_evaluation_analysable_file($modelid, $analysableid, $timesplittingid) {
// Delete previous file if it exists.
$fs = get_file_storage();
// Always evaluation.csv and labelled as it is an evaluation file.
$filearea = self::LABELLED_FILEAREA;
$filename = self::get_filename(true);
$filepath = '/analysable/' . $analysableid . '/' .
\core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/';
return $fs->get_file(\context_system::instance()->id, 'analytics', $filearea, $modelid, $filepath, $filename);
}
/**
* Merge multiple files into one.
*
* Important! It is the caller responsability to ensure that the datasets are compatible.
*
* @param array $files
* @param int $modelid
* @param string $timesplittingid
* @param string $filearea
* @param bool $evaluation
* @return \stored_file
*/
public static function merge_datasets(array $files, $modelid, $timesplittingid, $filearea, $evaluation = false) {
$tmpfilepath = make_request_directory() . DIRECTORY_SEPARATOR . 'tmpfile.csv';
// Add headers.
// We could also do this with a single iteration gathering all files headers and appending them to the beginning of the file
// once all file contents are merged.
$varnames = '';
$analysablesvalues = array();
foreach ($files as $file) {
$rh = $file->get_content_file_handle();
// Copy the var names as they are, all files should have the same var names.
$varnames = fgetcsv($rh);
$analysablesvalues[] = fgetcsv($rh);
// Copy the columns as they are, all files should have the same columns.
$columns = fgetcsv($rh);
}
// Merge analysable values skipping the ones that are the same in all analysables.
$values = array();
foreach ($analysablesvalues as $analysablevalues) {
foreach ($analysablevalues as $varkey => $value) {
// Sha1 to make it unique.
$values[$varkey][sha1($value)] = $value;
}
}
foreach ($values as $varkey => $varvalues) {
$values[$varkey] = implode('|', $varvalues);
}
// Start writing to the merge file.
$wh = fopen($tmpfilepath, 'w');
if (!$wh) {
throw new \moodle_exception('errorcannotwritedataset', 'analytics', '', $tmpfilepath);
}
fputcsv($wh, $varnames);
fputcsv($wh, $values);
fputcsv($wh, $columns);
// Iterate through all files and add them to the tmp one. We don't want file contents in memory.
foreach ($files as $file) {
$rh = $file->get_content_file_handle();
// Skip headers.
fgets($rh);
fgets($rh);
fgets($rh);
// Copy all the following lines.
while ($line = fgets($rh)) {
fwrite($wh, $line);
}
fclose($rh);
}
fclose($wh);
$filerecord = [
'component' => 'analytics',
'filearea' => $filearea,
'itemid' => $modelid,
'contextid' => \context_system::instance()->id,
'filepath' => '/timesplitting/' . \core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/',
'filename' => self::get_filename($evaluation)
];
$fs = get_file_storage();
return $fs->create_file_from_pathname($filerecord, $tmpfilepath);
}
/**
* Exports the model training data.
*
* @param int $modelid
* @param string $timesplittingid
* @return \stored_file|false
*/
public static function export_training_data($modelid, $timesplittingid) {
$fs = get_file_storage();
$contextid = \context_system::instance()->id;
$filepath = '/timesplitting/' . \core_analytics\analysis::clean_time_splitting_id($timesplittingid) . '/';
$files = $fs->get_directory_files($contextid, 'analytics', self::LABELLED_FILEAREA, $modelid,
$filepath, true, false);
// Discard evaluation files.
foreach ($files as $key => $file) {
if ($file->get_filename() === self::EVALUATION_FILENAME) {
unset($files[$key]);
}
}
if (empty($files)) {
return false;
}
return self::merge_datasets($files, $modelid, $timesplittingid, self::EXPORT_FILEAREA);
}
/**
* Returns the dataset file data structured by sampleids using the indicators and target column names.
*
* @param \stored_file $dataset
* @return array
*/
public static function get_structured_data(\stored_file $dataset) {
if ($dataset->get_filearea() !== 'unlabelled') {
throw new \coding_exception('Sorry, only support for unlabelled data');
}
$rh = $dataset->get_content_file_handle();
// Skip dataset info.
fgets($rh);
fgets($rh);
$calculations = array();
$headers = fgetcsv($rh);
// Get rid of the sampleid column name.
array_shift($headers);
while ($columns = fgetcsv($rh)) {
$uniquesampleid = array_shift($columns);
// Unfortunately fgetcsv does not respect line's var types.
$calculations[$uniquesampleid] = array_map(function($value) {
if ($value === '') {
// We really want them as null because converted to float become 0
// and we need to treat the values separately.
return null;
} else if (is_numeric($value)) {
return floatval($value);
}
return $value;
}, array_combine($headers, $columns));
}
return $calculations;
}
/**
* Delete all files of a model.
*
* @param int $modelid
* @return bool
*/
public static function clear_model_files($modelid) {
$fs = get_file_storage();
return $fs->delete_area_files(\context_system::instance()->id, 'analytics', false, $modelid);
}
/**
* Returns the file name to be used.
*
* @param strinbool $evaluation
* @return string
*/
protected static function get_filename($evaluation) {
if ($evaluation === true) {
$filename = self::EVALUATION_FILENAME;
} else {
// Incremental time, the lock will make sure we don't have concurrency problems.
$filename = microtime(true) . '.csv';
}
return $filename;
}
}
+126
View File
@@ -0,0 +1,126 @@
<?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/>.
/**
* Default list of bulk actions to reuse across different targets as presets.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Default list of bulk actions to reuse across different targets as presets.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class default_bulk_actions {
/**
* Accepted prediction.
*
* @return \core_analytics\bulk_action
*/
public static function accept() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_FIXED
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_FIXED,
new \moodle_url(''), new \pix_icon('t/check', get_string('fixedack', 'analytics')),
get_string('fixedack', 'analytics'), false, $attrs, action::TYPE_POSITIVE);
}
/**
* The prediction is not applicable for this same (e.g. This student was unenrolled in the uni SIS).
*
* @return \core_analytics\bulk_action
*/
public static function not_applicable() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_NOT_APPLICABLE
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_NOT_APPLICABLE,
new \moodle_url(''), new \pix_icon('fp/cross', get_string('notapplicable', 'analytics'), 'theme'),
get_string('notapplicable', 'analytics'), false, $attrs, action::TYPE_NEUTRAL);
}
/**
* Incorrectly flagged prediction, useful for models based on data.
*
* @return \core_analytics\bulk_action
*/
public static function incorrectly_flagged() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_INCORRECTLY_FLAGGED
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_INCORRECTLY_FLAGGED,
new \moodle_url(''), new \pix_icon('i/incorrect', get_string('incorrectlyflagged', 'analytics')),
get_string('incorrectlyflagged', 'analytics'), false, $attrs, action::TYPE_NEGATIVE);
}
/**
* Useful prediction.
*
* @return \core_analytics\bulk_action
*/
public static function useful() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_USEFUL
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_USEFUL,
new \moodle_url(''), new \pix_icon('t/check', get_string('useful', 'analytics')),
get_string('useful', 'analytics'), false, $attrs, action::TYPE_POSITIVE);
}
/**
* Not useful prediction.
*
* @return \core_analytics\bulk_action
*/
public static function not_useful() {
$attrs = [
'data-bulk-actionname' => prediction::ACTION_NOT_USEFUL
] + self::bulk_action_base_attrs();
return new bulk_action(prediction::ACTION_NOT_USEFUL,
new \moodle_url(''), new \pix_icon('t/delete', get_string('notuseful', 'analytics')),
get_string('notuseful', 'analytics'), false, $attrs, action::TYPE_NEGATIVE);
}
/**
* Common attributes for all the action renderables.
*
* @return array
*/
private static function bulk_action_base_attrs() {
return [
'disabled' => 'disabled',
'data-toggle' => 'action',
'data-action' => 'toggle',
];
}
}
+275
View File
@@ -0,0 +1,275 @@
<?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/>.
/**
* Insights generator.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/lib/messagelib.php');
/**
* Insights generator.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class insights_generator {
/**
* @var int
*/
private $modelid;
/**
* @var \core_analytics\local\target\base
*/
private $target;
/**
* @var int[]
*/
private $contextcourseids;
/**
* Constructor.
*
* @param int $modelid
* @param \core_analytics\local\target\base $target
*/
public function __construct(int $modelid, \core_analytics\local\target\base $target) {
$this->modelid = $modelid;
$this->target = $target;
}
/**
* Generates insight notifications.
*
* @param array $samplecontexts The contexts these predictions belong to
* @param \core_analytics\prediction[] $predictions The prediction records
* @return null
*/
public function generate($samplecontexts, $predictions) {
$analyserclass = $this->target->get_analyser_class();
// We will need to restore it later.
$actuallanguage = current_language();
if ($analyserclass::one_sample_per_analysable()) {
// Iterate through the predictions and the users in each prediction (likely to be just one).
foreach ($predictions as $prediction) {
$context = $samplecontexts[$prediction->get_prediction_data()->contextid];
$users = $this->target->get_insights_users($context);
foreach ($users as $user) {
$this->set_notification_language($user);
list($insighturl, $fullmessage, $fullmessagehtml) = $this->prediction_info($prediction, $context, $user);
$this->notification($context, $user, $insighturl, $fullmessage, $fullmessagehtml);
}
}
} else {
// Iterate through the context and the users in each context.
foreach ($samplecontexts as $context) {
// Weird to pass both the context and the contextname to a method right, but this way we don't add unnecessary
// db reads calling get_context_name() multiple times.
$contextname = $context->get_context_name(false);
$users = $this->target->get_insights_users($context);
foreach ($users as $user) {
$this->set_notification_language($user);
$insighturl = $this->target->get_insight_context_url($this->modelid, $context);
list($fullmessage, $fullmessagehtml) = $this->target->get_insight_body($context, $contextname, $user,
$insighturl);
$this->notification($context, $user, $insighturl, $fullmessage, $fullmessagehtml);
}
}
}
force_current_language($actuallanguage);
}
/**
* Generates a insight notification for the user.
*
* @param \context $context
* @param \stdClass $user
* @param \moodle_url $insighturl The insight URL
* @param string $fullmessage
* @param string $fullmessagehtml
* @return null
*/
private function notification(\context $context, \stdClass $user, \moodle_url $insighturl, string $fullmessage, string $fullmessagehtml) {
$message = new \core\message\message();
$message->component = 'moodle';
$message->name = 'insights';
$message->userfrom = \core_user::get_support_user();
$message->userto = $user;
$message->subject = $this->target->get_insight_subject($this->modelid, $context);
// Same than the subject.
$message->contexturlname = $message->subject;
$message->courseid = $this->get_context_courseid($context);
$message->fullmessage = $fullmessage;
$message->fullmessageformat = FORMAT_PLAIN;
$message->fullmessagehtml = $fullmessagehtml;
$message->smallmessage = $fullmessage;
$message->contexturl = $insighturl->out(false);
message_send($message);
}
/**
* Returns the course context of the provided context reading an internal cache first.
*
* @param \context $context
* @return int
*/
private function get_context_courseid(\context $context) {
if (empty($this->contextcourseids[$context->id])) {
$coursecontext = $context->get_course_context(false);
if (!$coursecontext) {
// Default to the frontpage course context.
$coursecontext = \context_course::instance(SITEID);
}
$this->contextcourseids[$context->id] = $coursecontext->instanceid;
}
return $this->contextcourseids[$context->id];
}
/**
* Extracts info from the prediction for display purposes.
*
* @param \core_analytics\prediction $prediction
* @param \context $context
* @param \stdClass $user
* @return array Three items array with formats [\moodle_url, string, string]
*/
private function prediction_info(\core_analytics\prediction $prediction, \context $context, \stdClass $user) {
global $OUTPUT;
// The prediction actions get passed to the target so that it can show them in its preferred way.
$actions = array_merge(
$this->target->prediction_actions($prediction, true, true),
$this->target->bulk_actions([$prediction])
);
$predictioninfo = $this->target->get_insight_body_for_prediction($context, $user, $prediction, $actions);
// For FORMAT_PLAIN.
$fullmessageplaintext = '';
if (!empty($predictioninfo[FORMAT_PLAIN])) {
$fullmessageplaintext .= $predictioninfo[FORMAT_PLAIN];
}
$insighturl = $predictioninfo['url'] ?? null;
// For FORMAT_HTML.
$messageactions = [];
foreach ($actions as $action) {
if (!$action->get_url()->get_param('forwardurl')) {
$params = ['actionvisiblename' => $action->get_text(), 'target' => '_blank'];
$actiondoneurl = new \moodle_url('/report/insights/done.php', $params);
// Set the forward url to the 'done' script.
$action->get_url()->param('forwardurl', $actiondoneurl->out(false));
}
if ($action->get_url()->param('predictionid') === null) {
// Bulk actions do not include the prediction id by default.
$action->get_url()->param('predictionid', $prediction->get_prediction_data()->id);
}
if (empty($insighturl)) {
// Ideally the target provides us with the best URL for the insight, if it doesn't we default
// to the first actions.
$insighturl = $action->get_url();
}
$actiondata = (object)['url' => $action->get_url()->out(false), 'text' => $action->get_text()];
// Basic message for people who still lives in the 90s.
$fullmessageplaintext .= get_string('insightinfomessageaction', 'analytics', $actiondata) . PHP_EOL;
// We now process the HTML version actions, with a special treatment for useful/notuseful.
if ($action->get_action_name() === 'useful') {
$usefulurl = $actiondata->url;
} else if ($action->get_action_name() === 'notuseful') {
$notusefulurl = $actiondata->url;
} else {
$messageactions[] = $actiondata;
}
}
// Extra condition because we don't want to show the yes/no unless we have urls for both of them.
if (!empty($usefulurl) && !empty($notusefulurl)) {
$usefulbuttons = ['usefulurl' => $usefulurl, 'notusefulurl' => $notusefulurl];
}
$contextinfo = [
'usefulbuttons' => !empty($usefulbuttons) ? $usefulbuttons : false,
'actions' => $messageactions,
'body' => $predictioninfo[FORMAT_HTML] ?? ''
];
$fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message_prediction', $contextinfo);
return [$insighturl, $fullmessageplaintext, $fullmessagehtml];
}
/**
* Sets the session language to the language used by the notification receiver.
*
* @param \stdClass $user The user who will receive the message
* @return null
*/
private function set_notification_language($user) {
global $CFG;
// Copied from current_language().
if (!empty($user->lang)) {
$lang = $user->lang;
} else if (isset($CFG->lang)) {
$lang = $CFG->lang;
} else {
$lang = 'en';
}
force_current_language($lang);
}
}
+525
View File
@@ -0,0 +1,525 @@
<?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/>.
/**
* Analysers base class.
*
* @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\local\analyser;
defined('MOODLE_INTERNAL') || die();
/**
* Analysers base class.
*
* @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
*/
abstract class base {
/**
* @var int
*/
protected $modelid;
/**
* The model target.
*
* @var \core_analytics\local\target\base
*/
protected $target;
/**
* The model indicators.
*
* @var \core_analytics\local\indicator\base[]
*/
protected $indicators;
/**
* Time splitting methods to use.
*
* Multiple time splitting methods during evaluation and 1 single
* time splitting method once the model is enabled.
*
* @var \core_analytics\local\time_splitting\base[]
*/
protected $timesplittings;
/**
* Execution options.
*
* @var array
*/
protected $options;
/**
* Simple log array.
*
* @var string[]
*/
protected $log;
/**
* Constructor method.
*
* @param int $modelid
* @param \core_analytics\local\target\base $target
* @param \core_analytics\local\indicator\base[] $indicators
* @param \core_analytics\local\time_splitting\base[] $timesplittings
* @param array $options
* @return void
*/
public function __construct($modelid, \core_analytics\local\target\base $target, $indicators, $timesplittings, $options) {
$this->modelid = $modelid;
$this->target = $target;
$this->indicators = $indicators;
$this->timesplittings = $timesplittings;
if (empty($options['evaluation'])) {
$options['evaluation'] = false;
}
$this->options = $options;
// Checks if the analyser satisfies the indicators requirements.
$this->check_indicators_requirements();
$this->log = array();
}
/**
* @deprecated since Moodle 3.7
*/
public function get_analysables() {
throw new \coding_exception('get_analysables() method has been removed and cannot be used any more.');
}
/**
* Returns the list of analysable elements available on the site.
*
* A relatively complex SQL query should be set so that we take into account which analysable elements
* have already been processed and the order in which they have been processed. Helper methods are available
* to ease to implementation of get_analysables_iterator: get_iterator_sql and order_sql.
*
* @param string|null $action 'prediction', 'training' or null if no specific action needed.
* @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
* @return \Iterator
*/
abstract public function get_analysables_iterator(?string $action = null, array $contexts = []);
/**
* This function returns this analysable list of samples.
*
* @param \core_analytics\analysable $analysable
* @return array array[0] = int[] (sampleids) and array[1] = array (samplesdata)
*/
abstract public function get_all_samples(\core_analytics\analysable $analysable);
/**
* This function returns the samples data from a list of sample ids.
*
* @param int[] $sampleids
* @return array array[0] = int[] (sampleids) and array[1] = array (samplesdata)
*/
abstract public function get_samples($sampleids);
/**
* Returns the analysable of a sample.
*
* @param int $sampleid
* @return \core_analytics\analysable
*/
abstract public function get_sample_analysable($sampleid);
/**
* Returns the sample's origin in moodle database.
*
* @return string
*/
abstract public function get_samples_origin();
/**
* Returns the context of a sample.
*
* moodle/analytics:listinsights will be required at this level to access the sample predictions.
*
* @param int $sampleid
* @return \context
*/
abstract public function sample_access_context($sampleid);
/**
* Describes a sample with a description summary and a \renderable (an image for example)
*
* @param int $sampleid
* @param int $contextid
* @param array $sampledata
* @return array array(string, \renderable)
*/
abstract public function sample_description($sampleid, $contextid, $sampledata);
/**
* Model id getter.
* @return int
*/
public function get_modelid(): int {
return $this->modelid;
}
/**
* Options getter.
* @return array
*/
public function get_options(): array {
return $this->options;
}
/**
* Returns the analysed target.
*
* @return \core_analytics\local\target\base
*/
public function get_target(): \core_analytics\local\target\base {
return $this->target;
}
/**
* Getter for time splittings.
*
* @return \core_analytics\local\time_splitting\base
*/
public function get_timesplittings(): array {
return $this->timesplittings;
}
/**
* Getter for indicators.
*
* @return \core_analytics\local\indicator\base
*/
public function get_indicators(): array {
return $this->indicators;
}
/**
* Instantiate the indicators.
*
* @return \core_analytics\local\indicator\base[]
*/
public function instantiate_indicators() {
foreach ($this->indicators as $key => $indicator) {
$this->indicators[$key] = call_user_func(array($indicator, 'instance'));
}
// Free memory ASAP.
gc_collect_cycles();
gc_mem_caches();
return $this->indicators;
}
/**
* Samples data this analyser provides.
*
* @return string[]
*/
protected function provided_sample_data() {
return array($this->get_samples_origin());
}
/**
* Returns labelled data (training and evaluation).
*
* @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return \stored_file[]
*/
public function get_labelled_data(array $contexts = []) {
// Delegates all processing to the analysis.
$result = new \core_analytics\local\analysis\result_file($this->get_modelid(), true, $this->get_options());
$analysis = new \core_analytics\analysis($this, true, $result);
$analysis->run($contexts);
return $result->get();
}
/**
* Returns unlabelled data (prediction).
*
* @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return \stored_file[]
*/
public function get_unlabelled_data(array $contexts = []) {
// Delegates all processing to the analysis.
$result = new \core_analytics\local\analysis\result_file($this->get_modelid(), false, $this->get_options());
$analysis = new \core_analytics\analysis($this, false, $result);
$analysis->run($contexts);
return $result->get();
}
/**
* Returns indicator calculations as an array.
*
* @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
* @return array
*/
public function get_static_data(array $contexts = []) {
// Delegates all processing to the analysis.
$result = new \core_analytics\local\analysis\result_array($this->get_modelid(), false, $this->get_options());
$analysis = new \core_analytics\analysis($this, false, $result);
$analysis->run($contexts);
return $result->get();
}
/**
* Checks if the analyser satisfies all the model indicators requirements.
*
* @throws \core_analytics\requirements_exception
* @return void
*/
protected function check_indicators_requirements() {
foreach ($this->indicators as $indicator) {
$missingrequired = $this->check_indicator_requirements($indicator);
if ($missingrequired !== true) {
throw new \core_analytics\requirements_exception(get_class($indicator) . ' indicator requires ' .
json_encode($missingrequired) . ' sample data which is not provided by ' . get_class($this));
}
}
}
/**
* Checks that this analyser satisfies the provided indicator requirements.
*
* @param \core_analytics\local\indicator\base $indicator
* @return true|string[] True if all good, missing requirements list otherwise
*/
public function check_indicator_requirements(\core_analytics\local\indicator\base $indicator) {
$providedsampledata = $this->provided_sample_data();
$requiredsampledata = $indicator::required_sample_data();
if (empty($requiredsampledata)) {
// The indicator does not need any sample data.
return true;
}
$missingrequired = array_diff($requiredsampledata, $providedsampledata);
if (empty($missingrequired)) {
return true;
}
return $missingrequired;
}
/**
* Adds a register to the analysis log.
*
* @param string $string
* @return void
*/
public function add_log($string) {
$this->log[] = $string;
}
/**
* Returns the analysis logs.
*
* @return string[]
*/
public function get_logs() {
return $this->log;
}
/**
* Whether the plugin needs user data clearing or not.
*
* This is related to privacy. Override this method if your analyser samples have any relation
* to the 'user' database entity. We need to clean the site from all user-related data if a user
* request their data to be deleted from the system. A static::provided_sample_data returning 'user'
* is an indicator that you should be returning true.
*
* @return bool
*/
public function processes_user_data() {
return false;
}
/**
* SQL JOIN from a sample to users table.
*
* This function should be defined if static::processes_user_data returns true and it is related to analytics API
* privacy API implementation. It allows the analytics API to identify data associated to users that needs to be
* deleted or exported.
*
* This function receives the alias of a table with a 'sampleid' field and it should return a SQL join
* with static::get_samples_origin and with 'user' table. Note that:
* - The function caller expects the returned 'user' table to be aliased as 'u' (defacto standard in moodle).
* - You can join with other tables if your samples origin table does not contain a 'userid' field (if that would be
* a requirement this solution would be automated for you) you can't though use the following
* aliases: 'ap', 'apa', 'aic' and 'am'.
*
* Some examples:
*
* static::get_samples_origin() === 'user':
* JOIN {user} u ON {$sampletablealias}.sampleid = u.id
*
* static::get_samples_origin() === 'role_assignments':
* JOIN {role_assignments} ra ON {$sampletablealias}.sampleid = ra.userid JOIN {user} u ON u.id = ra.userid
*
* static::get_samples_origin() === 'user_enrolments':
* JOIN {user_enrolments} ue ON {$sampletablealias}.sampleid = ue.userid JOIN {user} u ON u.id = ue.userid
*
* @throws \coding_exception
* @param string $sampletablealias The alias of the table with a sampleid field that will join with this SQL string
* @return string
*/
public function join_sample_user($sampletablealias) {
throw new \coding_exception('This method should be implemented if static::processes_user_data returns true.');
}
/**
* Do this analyser's analysables have 1 single sample each?
*
* Overwrite and return true if your analysables only have
* one sample. The insights generated by models using this
* analyser will then include the suggested actions in the
* notification.
*
* @return bool
*/
public static function one_sample_per_analysable() {
return false;
}
/**
* Returns an array of context levels that can be used to restrict the contexts used during analysis.
*
* The contexts provided to self::get_analysables_iterator will match these contextlevels.
*
* @return array Array of context levels or an empty array if context restriction is not supported.
*/
public static function context_restriction_support(): array {
return [];
}
/**
* Returns the possible contexts used by the analyser.
*
* This method uses separate logic for each context level because to iterate through
* the list of contexts calling get_context_name for each of them would be expensive
* in performance terms.
*
* This generic implementation returns all the contexts in the site for the provided context level.
* Overwrite it for specific restrictions in your analyser.
*
* @param string|null $query Context name filter.
* @return int[]
*/
public static function potential_context_restrictions(string $query = null) {
return \core_analytics\manager::get_potential_context_restrictions(static::context_restriction_support(), $query);
}
/**
* Get the sql of a default implementation of the iterator.
*
* This method only works for analysers that return analysable elements which ids map to a context instance ids.
*
* @param string $tablename The name of the table
* @param int $contextlevel The context level of the analysable
* @param string|null $action
* @param string|null $tablealias The table alias
* @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables if empty.
* @return array [0] => sql and [1] => params array
*/
protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null,
array $contexts = []) {
global $DB;
if (!$tablealias) {
$tablealias = 'analysable';
}
$params = ['contextlevel' => $contextlevel, 'modelid' => $this->get_modelid()];
$select = $tablealias . '.*, ' . \context_helper::get_preload_record_columns_sql('ctx');
// We add the action filter on ON instead of on WHERE because otherwise records are not returned if there are existing
// records for another action or model.
$usedanalysablesjoin = ' LEFT JOIN {analytics_used_analysables} aua ON ' . $tablealias . '.id = aua.analysableid AND ' .
'(aua.modelid = :modelid OR aua.modelid IS NULL)';
if ($action) {
$usedanalysablesjoin .= " AND aua.action = :action";
$params = $params + ['action' => $action];
}
$sql = 'SELECT ' . $select . '
FROM {' . $tablename . '} ' . $tablealias . '
' . $usedanalysablesjoin . '
JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id) ';
if (!$contexts) {
// Adding the 1 = 1 just to have the WHERE part so that all further conditions
// added by callers can be appended to $sql with and ' AND'.
$sql .= 'WHERE 1 = 1';
} else {
$contextsqls = [];
foreach ($contexts as $context) {
$paramkey1 = 'paramctxlike' . $context->id;
$paramkey2 = 'paramctxeq' . $context->id;
$contextsqls[] = $DB->sql_like('ctx.path', ':' . $paramkey1);
$contextsqls[] = 'ctx.path = :' . $paramkey2;
// This includes the context itself.
$params[$paramkey1] = $context->path . '/%';
$params[$paramkey2] = $context->path;
}
$sql .= 'WHERE (' . implode(' OR ', $contextsqls) . ')';
}
return [$sql, $params];
}
/**
* Returns the order by clause.
*
* @param string|null $fieldname The field name
* @param string $order 'ASC' or 'DESC'
* @param string|null $tablealias The table alias of the field
* @return string
*/
protected function order_sql(?string $fieldname = null, string $order = 'ASC', ?string $tablealias = null) {
if (!$tablealias) {
$tablealias = 'analysable';
}
if ($order != 'ASC' && $order != 'DESC') {
throw new \coding_exception('The order can only be ASC or DESC');
}
$ordersql = ' ORDER BY (CASE WHEN aua.timeanalysed IS NULL THEN 0 ELSE aua.timeanalysed END) ASC';
if ($fieldname) {
$ordersql .= ', ' . $tablealias . '.' . $fieldname .' ' . $order;
}
return $ordersql;
}
}
@@ -0,0 +1,77 @@
<?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/>.
/**
* Abstract analyser in course basis.
*
* @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\local\analyser;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract analyser in course basis.
*
* @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
*/
abstract class by_course extends base {
/**
* Return the list of courses to analyse.
*
* @param string|null $action 'prediction', 'training' or null if no specific action needed.
* @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
* @return \Iterator
*/
public function get_analysables_iterator(?string $action = null, array $contexts = []) {
global $DB;
list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c', $contexts);
$ordersql = $this->order_sql('sortorder', 'ASC', 'c');
$recordset = $DB->get_recordset_sql($sql . $ordersql, $params);
if (!$recordset->valid()) {
$this->add_log(get_string('nocourses', 'analytics'));
return new \ArrayIterator([]);
}
return new \core\dml\recordset_walk($recordset, function($record) {
if ($record->id == SITEID) {
return false;
}
$context = \context_helper::preload_from_record($record);
return \core_analytics\course::instance($record, $context);
});
}
/**
* Can be limited to course categories or specific courses.
*
* @return array
*/
public static function context_restriction_support(): array {
return [CONTEXT_COURSE, CONTEXT_COURSECAT];
}
}
@@ -0,0 +1,49 @@
<?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/>.
/**
* Site-level contents abstract analysable.
*
* @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\local\analyser;
defined('MOODLE_INTERNAL') || die();
/**
* Site-level contents abstract analysable.
*
* @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
*/
abstract class sitewide extends base {
/**
* Return the list of analysables to analyse.
*
* @param string|null $action 'prediction', 'training' or null if no specific action needed.
* @param \context[] $contexts Ignored here.
* @return \Iterator
*/
public function get_analysables_iterator(?string $action = null, array $contexts = []) {
// We can safely ignore $action as we have 1 single analysable element in this analyser.
return new \ArrayIterator([new \core_analytics\site()]);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?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/>.
/**
* Keeps track of the analysis results.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\analysis;
defined('MOODLE_INTERNAL') || die();
/**
* Keeps track of the analysis results.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class result {
/**
* @var int
*/
protected $modelid;
/**
* @var bool
*/
protected $includetarget;
/**
* @var array Analysis options
*/
protected $options;
/**
* Stores analysis data at instance level.
* @param int $modelid
* @param bool $includetarget
* @param array $options
*/
public function __construct(int $modelid, bool $includetarget, array $options) {
$this->modelid = $modelid;
$this->includetarget = $includetarget;
$this->options = $options;
}
/**
* Retrieves cached results during evaluation.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed It can be in whatever format the result uses.
*/
public function retrieve_cached_result(\core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\analysable $analysable) {
return false;
}
/**
* Stores the analysis results.
*
* @param array $results
* @return bool True if anything was successfully analysed
*/
abstract public function add_analysable_results(array $results): bool;
/**
* Formats the result.
*
* @param array $data
* @param \core_analytics\local\target\base $target
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed It can be in whatever format the result uses
*/
abstract public function format_result(array $data, \core_analytics\local\target\base $target,
\core_analytics\local\time_splitting\base $timesplitting, \core_analytics\analysable $analysable);
/**
* Returns the results of the analysis.
* @return array
*/
abstract public function get(): array;
}
@@ -0,0 +1,100 @@
<?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/>.
/**
* Keeps track of the analysis results by storing the results in an array.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\analysis;
defined('MOODLE_INTERNAL') || die();
/**
* Keeps track of the analysis results by storing the results in an array.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class result_array extends result {
/**
* Stores the analysis results by time-splitting method.
* @var array
*/
private $resultsbytimesplitting = [];
/**
* Stores the analysis results.
* @param array $results
* @return bool True if anything was successfully analysed
*/
public function add_analysable_results(array $results): bool {
$any = false;
// Process all provided time splitting methods.
foreach ($results as $timesplittingid => $result) {
if (!empty($result->result)) {
if (empty($this->resultsbytimesplitting[$timesplittingid])) {
$this->resultsbytimesplitting[$timesplittingid] = [];
}
$this->resultsbytimesplitting[$timesplittingid] += $result->result;
$any = true;
}
}
if (empty($any)) {
return false;
}
return true;
}
/**
* Formats the result.
*
* @param array $data
* @param \core_analytics\local\target\base $target
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed The data as it comes
*/
public function format_result(array $data, \core_analytics\local\target\base $target,
\core_analytics\local\time_splitting\base $timesplitting, \core_analytics\analysable $analysable) {
return $data;
}
/**
* Returns the results of the analysis.
* @return array
*/
public function get(): array {
// We join the datasets by time splitting method.
$timesplittingresults = array();
foreach ($this->resultsbytimesplitting as $timesplittingid => $results) {
if (empty($timesplittingresults[$timesplittingid])) {
$timesplittingresults[$timesplittingid] = [];
}
$timesplittingresults[$timesplittingid] += $results;
}
return $timesplittingresults;
}
}
@@ -0,0 +1,228 @@
<?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/>.
/**
* Keeps track of the analysis results by storing the results in files.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\analysis;
defined('MOODLE_INTERNAL') || die();
/**
* Keeps track of the analysis results by storing the results in files.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class result_file extends result {
/**
* Stores the analysis results by time-splitting method.
* @var array
*/
private $filesbytimesplitting = [];
/**
* Stores the analysis results.
* @param array $results
* @return bool True if anything was successfully analysed
*/
public function add_analysable_results(array $results): bool {
$any = false;
// Process all provided time splitting methods.
foreach ($results as $timesplittingid => $result) {
if (!empty($result->result)) {
$this->filesbytimesplitting[$timesplittingid][] = $result->result;
$any = true;
}
}
if (empty($any)) {
return false;
}
return true;
}
/**
* Retrieves cached results during evaluation.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed A \stored_file in this case.
*/
public function retrieve_cached_result(\core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\analysable $analysable) {
// For evaluation purposes we don't need to be that strict about how updated the data is,
// if this analyser was analysed less that 1 week ago we skip generating a new one. This
// helps scale the evaluation process as sites with tons of courses may need a lot of time to
// complete an evaluation.
if (!empty($this->options['evaluation']) && !empty($this->options['reuseprevanalysed'])) {
$previousanalysis = \core_analytics\dataset_manager::get_evaluation_analysable_file($this->modelid,
$analysable->get_id(), $timesplitting->get_id());
// 1 week is a partly random time interval, no need to worry about DST.
$boundary = time() - WEEKSECS;
if ($previousanalysis && $previousanalysis->get_timecreated() > $boundary) {
// Recover the previous analysed file and avoid generating a new one.
return $previousanalysis;
}
}
return false;
}
/**
* Formats the result.
*
* @param array $data
* @param \core_analytics\local\target\base $target
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\analysable $analysable
* @return mixed A \stored_file in this case
*/
public function format_result(array $data, \core_analytics\local\target\base $target,
\core_analytics\local\time_splitting\base $timesplitting, \core_analytics\analysable $analysable) {
if (!empty($this->includetarget)) {
$filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
} else {
$filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
}
$dataset = new \core_analytics\dataset_manager($this->modelid, $analysable->get_id(),
$timesplitting->get_id(), $filearea, $this->options['evaluation']);
// Add extra metadata.
$this->add_model_metadata($data, $timesplitting, $target);
// Write all calculated data to a file.
if (!$result = $dataset->store($data)) {
return false;
}
return $result;
}
/**
* Returns the results of the analysis.
* @return array
*/
public function get(): array {
if ($this->options['evaluation'] === false) {
// Look for previous training and prediction files we generated and couldn't be used
// by machine learning backends because they weren't big enough.
$pendingfiles = \core_analytics\dataset_manager::get_pending_files($this->modelid, $this->includetarget,
array_keys($this->filesbytimesplitting));
foreach ($pendingfiles as $timesplittingid => $files) {
foreach ($files as $file) {
$this->filesbytimesplitting[$timesplittingid][] = $file;
}
}
}
// We join the datasets by time splitting method.
$timesplittingfiles = array();
foreach ($this->filesbytimesplitting as $timesplittingid => $files) {
if ($this->options['evaluation'] === true) {
// Delete the previous copy. Only when evaluating.
\core_analytics\dataset_manager::delete_previous_evaluation_file($this->modelid, $timesplittingid);
}
// Merge all course files into one.
if ($this->includetarget) {
$filearea = \core_analytics\dataset_manager::LABELLED_FILEAREA;
} else {
$filearea = \core_analytics\dataset_manager::UNLABELLED_FILEAREA;
}
$timesplittingfiles[$timesplittingid] = \core_analytics\dataset_manager::merge_datasets($files,
$this->modelid, $timesplittingid, $filearea, $this->options['evaluation']);
}
if (!empty($pendingfiles)) {
// We must remove them now as they are already part of another dataset.
foreach ($pendingfiles as $timesplittingid => $files) {
foreach ($files as $file) {
$file->delete();
}
}
}
return $timesplittingfiles;
}
/**
* Adds target metadata to the dataset.
*
* The final dataset document will look like this:
* ----------------------------------------------------
* metadata1,metadata2,metadata3,.....
* value1, value2, value3,.....
*
* header1,header2,header3,header4,.....
* stud1value1,stud1value2,stud1value3,stud1value4,.....
* stud2value1,stud2value2,stud2value3,stud2value4,.....
* .....
* ----------------------------------------------------
*
* @param array $data
* @param \core_analytics\local\time_splitting\base $timesplitting
* @param \core_analytics\local\target\base $target
* @return null
*/
private function add_model_metadata(array &$data, \core_analytics\local\time_splitting\base $timesplitting,
\core_analytics\local\target\base $target) {
global $CFG;
// If no target the first column is the sampleid, if target the last column is the target.
// This will need to be updated when we support unsupervised learning models.
$metadata = array(
'timesplitting' => $timesplitting->get_id(),
'nfeatures' => count(current($data)) - 1,
'moodleversion' => $CFG->version,
'targetcolumn' => $target->get_id()
);
if ($target->is_linear()) {
$metadata['targettype'] = 'linear';
$metadata['targetmin'] = $target::get_min_value();
$metadata['targetmax'] = $target::get_max_value();
} else {
$metadata['targettype'] = 'discrete';
$metadata['targetclasses'] = json_encode($target::get_classes());
}
// The first 2 samples will be used to store metadata about the dataset.
$metadatacolumns = [];
$metadatavalues = [];
foreach ($metadata as $key => $value) {
$metadatacolumns[] = $key;
$metadatavalues[] = $value;
}
// This will also reset samples' dataset keys.
array_unshift($data, $metadatacolumns, $metadatavalues);
}
}
+197
View File
@@ -0,0 +1,197 @@
<?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/>.
/**
* Abstract base indicator.
*
* @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\local\indicator;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract base indicator.
*
* @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
*/
abstract class base extends \core_analytics\calculable {
/**
* Min value an indicator can return.
*/
const MIN_VALUE = -1;
/**
* Max value an indicator can return.
*/
const MAX_VALUE = 1;
/**
* Converts the calculated indicators to dataset feature/s.
*
* @param float|int[] $calculatedvalues
* @return array
*/
abstract protected function to_features($calculatedvalues);
/**
* Calculates the sample.
*
* Return a value from self::MIN_VALUE to self::MAX_VALUE or null if the indicator can not be calculated for this sample.
*
* @param int $sampleid
* @param string $sampleorigin
* @param integer $starttime Limit the calculation to this timestart
* @param integer $endtime Limit the calculation to this timeend
* @return float|null
*/
abstract protected function calculate_sample($sampleid, $sampleorigin, $starttime, $endtime);
/**
* Should this value be displayed?
*
* Indicators providing multiple features can be used this method to discard some of them.
*
* @param float $value
* @param string $subtype
* @return bool
*/
public function should_be_displayed($value, $subtype) {
// We should everything by default.
return true;
}
/**
* Allows indicators to specify data they need.
*
* e.g. A model using courses as samples will not provide users data, but an indicator like
* "user is hungry" needs user data.
*
* @return null|string[] Name of the required elements (use the database tablename)
*/
public static function required_sample_data() {
return null;
}
/**
* Returns an instance of the indicator.
*
* Useful to reset cached data.
*
* @return \core_analytics\local\indicator\base
*/
public static function instance() {
return new static();
}
/**
* Returns the maximum value an indicator calculation can return.
*
* @return float
*/
public static function get_max_value() {
return self::MAX_VALUE;
}
/**
* Returns the minimum value an indicator calculation can return.
*
* @return float
*/
public static function get_min_value() {
return self::MIN_VALUE;
}
/**
* Hook to allow indicators to pre-fill data that is shared accross time range calculations.
*
* Useful to fill analysable-dependant data that does not depend on the time ranges. Use
* instance vars to cache data that can be re-used across samples calculations but changes
* between time ranges (indicator instances are reset between time ranges to avoid unexpected
* problems).
*
* You are also responsible of emptying previous analysable caches.
*
* @param \core_analytics\analysable $analysable
* @return void
*/
public function fill_per_analysable_caches(\core_analytics\analysable $analysable) {
}
/**
* Calculates the indicator.
*
* Returns an array of values which size matches $sampleids size.
*
* @param int[] $sampleids
* @param string $samplesorigin
* @param integer $starttime Limit the calculation to this timestart
* @param integer $endtime Limit the calculation to this timeend
* @param array $existingcalculations Existing calculations of this indicator, indexed by sampleid.
* @return array [0] = [$sampleid => int[]|float[]], [1] = [$sampleid => int|float], [2] = [$sampleid => $sampleid]
*/
public function calculate($sampleids, $samplesorigin, $starttime = false, $endtime = false, $existingcalculations = array()) {
if (!PHPUNIT_TEST && CLI_SCRIPT) {
echo '.';
}
$calculations = array();
$newcalculations = array();
$notnulls = array();
foreach ($sampleids as $sampleid => $unusedsampleid) {
if (isset($existingcalculations[$sampleid])) {
$calculatedvalue = $existingcalculations[$sampleid];
} else {
$calculatedvalue = $this->calculate_sample($sampleid, $samplesorigin, $starttime, $endtime);
$newcalculations[$sampleid] = $calculatedvalue;
}
if (!is_null($calculatedvalue)) {
$notnulls[$sampleid] = $sampleid;
$this->validate_calculated_value($calculatedvalue);
}
$calculations[$sampleid] = $calculatedvalue;
}
$features = $this->to_features($calculations);
return array($features, $newcalculations, $notnulls);
}
/**
* Validates the calculated value.
*
* @throws \coding_exception
* @param float $calculatedvalue
* @return true
*/
protected function validate_calculated_value($calculatedvalue) {
if ($calculatedvalue > self::MAX_VALUE || $calculatedvalue < self::MIN_VALUE) {
throw new \coding_exception('Calculated values should be higher than ' . self::MIN_VALUE .
' and lower than ' . self::MAX_VALUE . ' ' . $calculatedvalue . ' received');
}
return true;
}
}
@@ -0,0 +1,128 @@
<?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/>.
/**
* Abstract binary indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\indicator;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract binary indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class binary extends discrete {
/**
* get_classes
*
* @return array
*/
final public static function get_classes() {
return [-1, 1];
}
/**
* It should always be displayed.
*
* Binary values have no subtypes by default, please overwrite if
* your indicator is adding extra features.
*
* @param float $value
* @param string $subtype
* @return bool
*/
public function should_be_displayed($value, $subtype) {
if ($subtype != false) {
return false;
}
return true;
}
/**
* get_display_value
*
* @param float $value
* @param string $subtype
* @return string
*/
public function get_display_value($value, $subtype = false) {
// No subtypes for binary values by default.
if ($value == -1) {
return get_string('no');
} else if ($value == 1) {
return get_string('yes');
} else {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
}
/**
* get_calculation_outcome
*
* @param float $value
* @param string $subtype
* @return int
*/
public function get_calculation_outcome($value, $subtype = false) {
// No subtypes for binary values by default.
if ($value == -1) {
return self::OUTCOME_NEGATIVE;
} else if ($value == 1) {
return self::OUTCOME_OK;
} else {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
}
/**
* get_feature_headers
*
* @return array
*/
public static function get_feature_headers() {
// Just 1 single feature obtained from the calculated value.
return array('\\' . get_called_class());
}
/**
* to_features
*
* @param array $calculatedvalues
* @return array
*/
protected function to_features($calculatedvalues) {
// Indicators with binary values have only 1 feature for indicator, here we do nothing else
// than converting each sample scalar value to an array of scalars with 1 element.
array_walk($calculatedvalues, function(&$calculatedvalue) {
// Just return it as an array.
$calculatedvalue = array($calculatedvalue);
});
return $calculatedvalues;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
<?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/>.
/**
* Abstract discrete indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\indicator;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract discrete indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class discrete extends base {
/**
* Classes need to be defined so they can be converted internally to individual dataset features.
*
* @return string[]
*/
protected static function get_classes() {
throw new \coding_exception('Please overwrite get_classes() specifying your discrete-values\' indicator classes');
}
/**
* Returns 1 feature header for each of the classes.
*
* @return string[]
*/
public static function get_feature_headers() {
$fullclassname = '\\' . get_called_class();
foreach (static::get_classes() as $class) {
$headers[] = $fullclassname . '/' . $class;
}
return $headers;
}
/**
* Whether the value should be displayed or not.
*
* @param float $value
* @param string $subtype
* @return bool
*/
public function should_be_displayed($value, $subtype) {
if ($value != static::get_max_value()) {
// Discrete values indicators are converted internally to 1 feature per indicator, we are only interested
// in showing the feature flagged with the max value.
return false;
}
return true;
}
/**
* Returns the value to display when the prediction is $value.
*
* @param float $value
* @param string $subtype
* @return string
*/
public function get_display_value($value, $subtype = false) {
$displayvalue = array_search($subtype, static::get_classes(), false);
debugging('Please overwrite \core_analytics\local\indicator\discrete::get_display_value to show something ' .
'different than the default "' . $displayvalue . '"', DEBUG_DEVELOPER);
return $displayvalue;
}
/**
* get_display_style
*
* @param float $ignoredvalue
* @param string $ignoredsubtype
* @return int
*/
public function get_display_style($ignoredvalue, $ignoredsubtype) {
// No style attached to indicators classes, they are what they are, a cat,
// a horse or a sandwich, they are not good or bad.
return \core_analytics\calculable::OUTCOME_NEUTRAL;
}
/**
* From calculated values to dataset features.
*
* One column for each class.
*
* @param float[] $calculatedvalues
* @return float[]
*/
protected function to_features($calculatedvalues) {
$classes = static::get_classes();
foreach ($calculatedvalues as $sampleid => $calculatedvalue) {
// Using intval as it may come as a float from the db.
$classindex = array_search(intval($calculatedvalue), $classes, true);
if ($classindex === false && !is_null($calculatedvalue)) {
throw new \coding_exception(get_class($this) . ' calculated value "' . $calculatedvalue .
'" is not one of its defined classes (' . json_encode($classes) . ')');
}
// We transform the calculated value into multiple features, one for each of the possible classes.
$features = array_fill(0, count($classes), 0);
// 1 to the selected value.
if (!is_null($calculatedvalue)) {
$features[$classindex] = 1;
}
$calculatedvalues[$sampleid] = $features;
}
return $calculatedvalues;
}
/**
* Validates the calculated value.
*
* @param float $calculatedvalue
* @return true
*/
protected function validate_calculated_value($calculatedvalue) {
// Using intval as it may come as a float from the db.
if (!in_array(intval($calculatedvalue), static::get_classes())) {
throw new \coding_exception(get_class($this) . ' calculated value "' . $calculatedvalue .
'" is not one of its defined classes (' . json_encode(static::get_classes()) . ')');
}
return true;
}
}
@@ -0,0 +1,147 @@
<?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/>.
/**
* Abstract linear indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\indicator;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract linear indicator.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class linear extends base {
/**
* Set to false to avoid context features to be added as dataset features.
*
* @return bool
*/
protected static function include_averages() {
return true;
}
/**
* get_feature_headers
*
* @return array
*/
public static function get_feature_headers() {
$fullclassname = '\\' . get_called_class();
if (static::include_averages()) {
// The calculated value + context indicators.
$headers = array($fullclassname, $fullclassname . '/mean');
} else {
$headers = array($fullclassname);
}
return $headers;
}
/**
* Show only the main feature.
*
* @param float $value
* @param string $subtype
* @return bool
*/
public function should_be_displayed($value, $subtype) {
if ($subtype != false) {
return false;
}
return true;
}
/**
* get_display_value
*
* @param float $value
* @param string $subtype
* @return string
*/
public function get_display_value($value, $subtype = false) {
$diff = static::get_max_value() - static::get_min_value();
return round(100 * ($value - static::get_min_value()) / $diff) . '%';
}
/**
* get_calculation_outcome
*
* @param float $value
* @param string $subtype
* @return int
*/
public function get_calculation_outcome($value, $subtype = false) {
if ($value < 0) {
return self::OUTCOME_NEGATIVE;
} else {
return self::OUTCOME_OK;
}
}
/**
* Converts the calculated values to a list of features for the dataset.
*
* @param array $calculatedvalues
* @return array
*/
protected function to_features($calculatedvalues) {
// Null mean if all calculated values are null.
$nullmean = true;
foreach ($calculatedvalues as $value) {
if (!is_null($value)) {
// Early break, we don't want to spend a lot of time here.
$nullmean = false;
break;
}
}
if ($nullmean) {
$mean = null;
} else {
$mean = round(array_sum($calculatedvalues) / count($calculatedvalues), 2);
}
foreach ($calculatedvalues as $sampleid => $calculatedvalue) {
if (!is_null($calculatedvalue)) {
$calculatedvalue = round($calculatedvalue, 2);
}
if (static::include_averages()) {
$calculatedvalues[$sampleid] = array($calculatedvalue, $mean);
} else {
// Basically just convert the scalar to an array of scalars with a single value.
$calculatedvalues[$sampleid] = array($calculatedvalue);
}
}
// Returns each sample as an array of values, appending the mean to the calculated value.
return $calculatedvalues;
}
}
+459
View File
@@ -0,0 +1,459 @@
<?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/>.
/**
* Abstract base target.
*
* @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\local\target;
defined('MOODLE_INTERNAL') || die();
/**
* Abstract base target.
*
* @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
*/
abstract class base extends \core_analytics\calculable {
/**
* This target have linear or discrete values.
*
* @return bool
*/
abstract public function is_linear();
/**
* Returns the analyser class that should be used along with this target.
*
* @return string The full class name as a string
*/
abstract public function get_analyser_class();
/**
* Allows the target to verify that the analysable is a good candidate.
*
* This method can be used as a quick way to discard invalid analysables.
* e.g. Imagine that your analysable don't have students and you need them.
*
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return true|string
*/
abstract public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true);
/**
* Is this sample from the $analysable valid?
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return bool
*/
abstract public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true);
/**
* Calculates this target for the provided samples.
*
* In case there are no values to return or the provided sample is not applicable just return null.
*
* @param int $sampleid
* @param \core_analytics\analysable $analysable
* @param int|false $starttime Limit calculations to start time
* @param int|false $endtime Limit calculations to end time
* @return float|null
*/
abstract protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false);
/**
* Can the provided time-splitting method be used on this target?.
*
* Time-splitting methods not matching the target requirements will not be selectable by models based on this target.
*
* @param \core_analytics\local\time_splitting\base $timesplitting
* @return bool
*/
abstract public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool;
/**
* Is this target generating insights?
*
* Defaults to true.
*
* @return bool
*/
public static function uses_insights() {
return true;
}
/**
* Should the insights of this model be linked from reports?
*
* @return bool
*/
public function link_insights_report(): bool {
return true;
}
/**
* Based on facts (processed by machine learning backends) by default.
*
* @return bool
*/
public static function based_on_assumptions() {
return false;
}
/**
* Update the last analysis time on analysable processed or always.
*
* If you overwrite this method to return false the last analysis time
* will only be recorded in DB when the element successfully analysed. You can
* safely return false for lightweight targets.
*
* @return bool
*/
public function always_update_analysis_time(): bool {
return true;
}
/**
* Suggested actions for a user.
*
* @param \core_analytics\prediction $prediction
* @param bool $includedetailsaction
* @param bool $isinsightuser Force all the available actions to be returned as it the user who
* receives the insight is the one logged in.
* @return \core_analytics\prediction_action[]
*/
public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false,
$isinsightuser = false) {
global $PAGE;
$predictionid = $prediction->get_prediction_data()->id;
$contextid = $prediction->get_prediction_data()->contextid;
$modelid = $prediction->get_prediction_data()->modelid;
$actions = array();
if ($this->link_insights_report() && $includedetailsaction) {
$predictionurl = new \moodle_url('/report/insights/prediction.php', array('id' => $predictionid));
$detailstext = $this->get_view_details_text();
$actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_PREDICTION_DETAILS, $prediction,
$predictionurl, new \pix_icon('t/preview', $detailstext),
$detailstext, false, [], \core_analytics\action::TYPE_NEUTRAL);
}
return $actions;
}
/**
* Suggested bulk actions for a user.
*
* @param \core_analytics\prediction[] $predictions List of predictions suitable for the bulk actions to use.
* @return \core_analytics\bulk_action[] The list of bulk actions.
*/
public function bulk_actions(array $predictions) {
$analyserclass = $this->get_analyser_class();
if ($analyserclass::one_sample_per_analysable()) {
// Default actions are useful / not useful.
$actions = [
\core_analytics\default_bulk_actions::useful(),
\core_analytics\default_bulk_actions::not_useful()
];
} else {
// Accept and not applicable.
$actions = [
\core_analytics\default_bulk_actions::accept(),
\core_analytics\default_bulk_actions::not_applicable()
];
if (!self::based_on_assumptions()) {
// We include incorrectly flagged.
$actions[] = \core_analytics\default_bulk_actions::incorrectly_flagged();
}
}
return $actions;
}
/**
* Adds the JS required to run the bulk actions.
*/
public function add_bulk_actions_js() {
global $PAGE;
$PAGE->requires->js_call_amd('report_insights/actions', 'initBulk', ['.insights-bulk-actions']);
}
/**
* Returns the view details link text.
* @return string
*/
private function get_view_details_text() {
if ($this->based_on_assumptions()) {
$analyserclass = $this->get_analyser_class();
if ($analyserclass::one_sample_per_analysable()) {
$detailstext = get_string('viewinsightdetails', 'analytics');
} else {
$detailstext = get_string('viewdetails', 'analytics');
}
} else {
$detailstext = get_string('viewprediction', 'analytics');
}
return $detailstext;
}
/**
* Callback to execute once a prediction has been returned from the predictions processor.
*
* Note that the analytics_predictions db record is not yet inserted.
*
* @param int $modelid
* @param int $sampleid
* @param int $rangeindex
* @param \context $samplecontext
* @param float|int $prediction
* @param float $predictionscore
* @return void
*/
public function prediction_callback($modelid, $sampleid, $rangeindex, \context $samplecontext, $prediction, $predictionscore) {
return;
}
/**
* Generates insights notifications
*
* @param int $modelid
* @param \context[] $samplecontexts
* @param \core_analytics\prediction[] $predictions
* @return void
*/
public function generate_insight_notifications($modelid, $samplecontexts, array $predictions = []) {
// Delegate the processing of insights to the insights_generator.
$insightsgenerator = new \core_analytics\insights_generator($modelid, $this);
$insightsgenerator->generate($samplecontexts, $predictions);
}
/**
* Returns the list of users that will receive insights notifications.
*
* Feel free to overwrite if you need to but keep in mind that moodle/analytics:listinsights
* or moodle/analytics:listowninsights capability is required to access the list of insights.
*
* @param \context $context
* @return array
*/
public function get_insights_users(\context $context) {
if ($context->contextlevel === CONTEXT_USER) {
if (!has_capability('moodle/analytics:listowninsights', $context, $context->instanceid)) {
$users = [];
} else {
$users = [$context->instanceid => \core_user::get_user($context->instanceid)];
}
} else if ($context->contextlevel >= CONTEXT_COURSE) {
// At course level or below only enrolled users although this is not ideal for
// teachers assigned at category level.
$users = get_enrolled_users($context, 'moodle/analytics:listinsights', 0, 'u.*', null, 0, 0, true);
} else {
$users = get_users_by_capability($context, 'moodle/analytics:listinsights');
}
return $users;
}
/**
* URL to the insight.
*
* @param int $modelid
* @param \context $context
* @return \moodle_url
*/
public function get_insight_context_url($modelid, $context) {
return new \moodle_url('/report/insights/insights.php?modelid=' . $modelid . '&contextid=' . $context->id);
}
/**
* The insight notification subject.
*
* This is just a default message, you should overwrite it for a custom insight message.
*
* @param int $modelid
* @param \context $context
* @return string
*/
public function get_insight_subject(int $modelid, \context $context) {
return get_string('insightmessagesubject', 'analytics', $context->get_context_name());
}
/**
* Returns the body message for an insight with multiple predictions.
*
* This default method is executed when the analysable used by the model generates multiple insight
* for each analysable (one_sample_per_analysable === false)
*
* @param \context $context
* @param string $contextname
* @param \stdClass $user
* @param \moodle_url $insighturl
* @return string[] The plain text message and the HTML message
*/
public function get_insight_body(\context $context, string $contextname, \stdClass $user, \moodle_url $insighturl): array {
global $OUTPUT;
$fullmessage = get_string('insightinfomessageplain', 'analytics', $insighturl->out(false));
$fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message',
['url' => $insighturl->out(false), 'insightinfomessage' => get_string('insightinfomessagehtml', 'analytics')]
);
return [$fullmessage, $fullmessagehtml];
}
/**
* Returns the body message for an insight for a single prediction.
*
* This default method is executed when the analysable used by the model generates one insight
* for each analysable (one_sample_per_analysable === true)
*
* @param \context $context
* @param \stdClass $user
* @param \core_analytics\prediction $prediction
* @param \core_analytics\action[] $actions Passed by reference to remove duplicate links to actions.
* @return array Plain text msg, HTML message and the main URL for this
* insight (you can return null if you are happy with the
* default insight URL calculated in prediction_info())
*/
public function get_insight_body_for_prediction(\context $context, \stdClass $user, \core_analytics\prediction $prediction,
array &$actions) {
// No extra message by default.
return [FORMAT_PLAIN => '', FORMAT_HTML => '', 'url' => null];
}
/**
* Returns an instance of the child class.
*
* Useful to reset cached data.
*
* @return \core_analytics\base\target
*/
public static function instance() {
return new static();
}
/**
* Defines a boundary to ignore predictions below the specified prediction score.
*
* Value should go from 0 to 1.
*
* @return float
*/
protected function min_prediction_score() {
// The default minimum discards predictions with a low score.
return \core_analytics\model::PREDICTION_MIN_SCORE;
}
/**
* This method determines if a prediction is interesing for the model or not.
*
* @param mixed $predictedvalue
* @param float $predictionscore
* @return bool
*/
public function triggers_callback($predictedvalue, $predictionscore) {
$minscore = floatval($this->min_prediction_score());
if ($minscore < 0) {
debugging(get_class($this) . ' minimum prediction score is below 0, please update it to a value between 0 and 1.');
} else if ($minscore > 1) {
debugging(get_class($this) . ' minimum prediction score is above 1, please update it to a value between 0 and 1.');
}
// We need to consider that targets may not have a min score.
if (!empty($minscore) && floatval($predictionscore) < $minscore) {
return false;
}
return true;
}
/**
* Calculates the target.
*
* Returns an array of values which size matches $sampleids size.
*
* Rows with null values will be skipped as invalid by time splitting methods.
*
* @param array $sampleids
* @param \core_analytics\analysable $analysable
* @param int $starttime
* @param int $endtime
* @return array The format to follow is [userid] = scalar|null
*/
public function calculate($sampleids, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
if (!PHPUNIT_TEST && CLI_SCRIPT) {
echo '.';
}
$calculations = [];
foreach ($sampleids as $sampleid => $unusedsampleid) {
// No time limits when calculating the target to train models.
$calculatedvalue = $this->calculate_sample($sampleid, $analysable, $starttime, $endtime);
if (!is_null($calculatedvalue)) {
if ($this->is_linear() &&
($calculatedvalue > static::get_max_value() || $calculatedvalue < static::get_min_value())) {
throw new \coding_exception('Calculated values should be higher than ' . static::get_min_value() .
' and lower than ' . static::get_max_value() . '. ' . $calculatedvalue . ' received');
} else if (!$this->is_linear() && static::is_a_class($calculatedvalue) === false) {
throw new \coding_exception('Calculated values should be one of the target classes (' .
json_encode(static::get_classes()) . '). ' . $calculatedvalue . ' received');
}
}
$calculations[$sampleid] = $calculatedvalue;
}
return $calculations;
}
/**
* Filters out invalid samples for training.
*
* @param int[] $sampleids
* @param \core_analytics\analysable $analysable
* @param bool $fortraining
* @return void
*/
public function filter_out_invalid_samples(&$sampleids, \core_analytics\analysable $analysable, $fortraining = true) {
foreach ($sampleids as $sampleid => $unusedsampleid) {
if (!$this->is_valid_sample($sampleid, $analysable, $fortraining)) {
// Skip it and remove the sample from the list of calculated samples.
unset($sampleids[$sampleid]);
}
}
}
}
+106
View File
@@ -0,0 +1,106 @@
<?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/>.
/**
* Binary classifier target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\target;
defined('MOODLE_INTERNAL') || die();
/**
* Binary classifier target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class binary extends discrete {
/**
* is_linear
*
* @return bool
*/
public function is_linear() {
return false;
}
/**
* Returns the target discrete values.
*
* Only useful for targets using discrete values, must be overwriten if it is the case.
*
* @return array
*/
final public static function get_classes() {
return array(0, 1);
}
/**
* Returns the predicted classes that will be ignored.
*
* @return array
*/
public function ignored_predicted_classes() {
// Zero-value class is usually ignored in binary classifiers.
return array(0);
}
/**
* Is the calculated value a positive outcome of this target?
*
* @param string $value
* @param string $ignoredsubtype
* @return int
*/
public function get_calculation_outcome($value, $ignoredsubtype = false) {
if (!self::is_a_class($value)) {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
if (in_array($value, $this->ignored_predicted_classes(), false)) {
// Just in case, if it is ignored the prediction should not even be recorded but if it would, it is ignored now,
// which should mean that is it nothing serious.
return self::OUTCOME_VERY_POSITIVE;
}
// By default binaries are danger when prediction = 1.
if ($value) {
return self::OUTCOME_VERY_NEGATIVE;
}
return self::OUTCOME_VERY_POSITIVE;
}
/**
* classes_description
*
* @return string[]
*/
protected static function classes_description() {
return array(
get_string('yes'),
get_string('no')
);
}
}
+176
View File
@@ -0,0 +1,176 @@
<?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/>.
/**
* Discrete values target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\target;
defined('MOODLE_INTERNAL') || die();
/**
* Discrete values target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class discrete extends base {
/**
* Are this target calculations linear values?
*
* @return bool
*/
public function is_linear() {
// Not supported yet.
throw new \coding_exception('Sorry, this version\'s prediction processors only support targets with binary values.' .
' You can write your own and overwrite this method though.');
}
/**
* Is the provided class one of this target valid classes?
*
* @param mixed $class
* @return bool
*/
protected static function is_a_class($class) {
return (in_array($class, static::get_classes(), false));
}
/**
* get_display_value
*
* @param float $value
* @param string $ignoredsubtype
* @return string
*/
public function get_display_value($value, $ignoredsubtype = false) {
if (!self::is_a_class($value)) {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
// To discard any possible weird keys devs used.
$classes = array_values(static::get_classes());
$descriptions = array_values(static::classes_description());
if (count($classes) !== count($descriptions)) {
throw new \coding_exception('You need to describe all your classes (' . json_encode($classes) .
') in self::classes_description');
}
$key = array_search($value, $classes);
if ($key === false) {
throw new \coding_exception('You need to describe all your classes (' . json_encode($classes) .
') in self::classes_description');
}
return $descriptions[$key];
}
/**
* get_calculation_outcome
*
* @param float $value
* @param string $ignoredsubtype
* @return int
*/
public function get_calculation_outcome($value, $ignoredsubtype = false) {
if (!self::is_a_class($value)) {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
if (in_array($value, $this->ignored_predicted_classes(), false)) {
// Just in case, if it is ignored the prediction should not even be recorded.
return self::OUTCOME_OK;
}
debugging('Please overwrite \core_analytics\local\target\discrete::get_calculation_outcome, all your target ' .
'classes are styled the same way otherwise', DEBUG_DEVELOPER);
return self::OUTCOME_OK;
}
/**
* Returns all the possible values the target calculation can return.
*
* Only useful for targets using discrete values, must be overwriten if it is the case.
*
* @return array
*/
public static function get_classes() {
// Coding exception as this will only be called if this target have non-linear values.
throw new \coding_exception('Overwrite get_classes() and return an array with the different values the ' .
'target calculation can return');
}
/**
* Returns descriptions for each of the values the target calculation can return.
*
* The array indexes should match self::get_classes indexes.
*
* @return array
*/
protected static function classes_description() {
throw new \coding_exception('Overwrite classes_description() and return an array with a description for each of the ' .
'different values the target calculation can return. Indexes should match self::get_classes indexes');
}
/**
* Returns the predicted classes that will be ignored.
*
* Better be keen to add more than less classes here, the callback is always able to discard some classes. As an example
* a target with classes 'grade 0-3', 'grade 3-6', 'grade 6-8' and 'grade 8-10' is interested in flagging both 'grade 6-8'
* and 'grade 8-10' as ignored. On the other hand, a target like dropout risk with classes 'yes', 'no' may just be
* interested in 'yes'.
*
* @return array List of values that will be ignored (array keys are ignored).
*/
public function ignored_predicted_classes() {
// Coding exception as this will only be called if this target have non-linear values.
throw new \coding_exception('Overwrite ignored_predicted_classes() and return an array with the classes that should not ' .
'trigger the callback');
}
/**
* This method determines if a prediction is interesing for the model or not.
*
* This method internally calls ignored_predicted_classes to skip classes
* flagged by the target as not important for users.
*
* @param mixed $predictedvalue
* @param float $predictionscore
* @return bool
*/
public function triggers_callback($predictedvalue, $predictionscore) {
if (!parent::triggers_callback($predictedvalue, $predictionscore)) {
return false;
}
if (in_array($predictedvalue, $this->ignored_predicted_classes())) {
return false;
}
return true;
}
}
+118
View File
@@ -0,0 +1,118 @@
<?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/>.
/**
* Linear values target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\target;
defined('MOODLE_INTERNAL') || die();
/**
* Linear values target.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class linear extends base {
/**
* Are the calculated values this target returns linear values?
*
* @return bool
*/
public function is_linear() {
// Not supported yet.
throw new \coding_exception('Sorry, this version\'s prediction processors only support targets with binary values.' .
' You can write your own and overwrite this method though.');
}
/**
* How positive is this calculated value?
*
* @param float $value
* @param string $ignoredsubtype
* @return int
*/
public function get_calculation_outcome($value, $ignoredsubtype = false) {
// This is very generic, targets will probably be interested in overwriting this.
$diff = static::get_max_value() - static::get_min_value();
if (($value - static::get_min_value()) / $diff >= 0.5) {
return self::OUTCOME_VERY_POSITIVE;
}
return self::OUTCOME_VERY_NEGATIVE;
}
/**
* Gets the maximum value for this target
*
* @return float
*/
public static function get_max_value() {
// Coding exception as this will only be called if this target have linear values.
throw new \coding_exception('Overwrite get_max_value() and return the target max value');
}
/**
* Gets the minimum value for this target
*
* @return float
*/
public static function get_min_value() {
// Coding exception as this will only be called if this target have linear values.
throw new \coding_exception('Overwrite get_min_value() and return the target min value');
}
/**
* This method determines if a prediction is interesing for the model or not.
*
* @param mixed $predictedvalue
* @param float $predictionscore
* @return bool
*/
public function triggers_callback($predictedvalue, $predictionscore) {
if (!parent::triggers_callback($predictedvalue, $predictionscore)) {
return false;
}
// People may not want to set a boundary.
$boundary = $this->get_callback_boundary();
if (!empty($boundary) && floatval($predictedvalue) < $boundary) {
return false;
}
return true;
}
/**
* Returns the minimum value that triggers the callback.
*
* @return float
*/
protected function get_callback_boundary() {
// Coding exception as this will only be called if this target have linear values.
throw new \coding_exception('Overwrite get_callback_boundary() and return the min value that ' .
'should trigger the callback');
}
}
@@ -0,0 +1,72 @@
<?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/>.
/**
* Range processor splitting the course in parts and accumulating data from the start.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Range processor splitting the course in parts and accumulating data from the start.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class accumulative_parts extends base implements before_now {
/**
* The number of parts to split the analysable duration in.
*
* @return int
*/
abstract protected function get_number_parts();
/**
* define_ranges
*
* @return array
*/
protected function define_ranges() {
$nparts = $this->get_number_parts();
$rangeduration = ($this->analysable->get_end() - $this->analysable->get_start()) / $nparts;
$ranges = array();
for ($i = 0; $i < $nparts; $i++) {
$end = $this->analysable->get_start() + intval($rangeduration * ($i + 1));
if ($i === ($nparts - 1)) {
// Better to use the end for the last one as we are using floor above.
$end = $this->analysable->get_end();
}
$ranges[$i] = array(
'start' => $this->analysable->get_start(),
'end' => $end,
'time' => $end
);
}
return $ranges;
}
}
@@ -0,0 +1,37 @@
<?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/>.
/**
* Interface for time-splitting methods whose ranges' times are after time().
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Interface for time-splitting methods whose ranges' times are after time().
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface after_now {
}
@@ -0,0 +1,111 @@
<?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/>.
/**
* Time splitting method that generates predictions X days/weeks/months after the analysable start.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Time splitting method that generates predictions X days/weeks/months after the analysable start.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class after_start extends \core_analytics\local\time_splitting\base implements before_now {
/**
* The period we should wait until we generate predictions for this.
*
* @param \core_analytics\analysable $analysable
* @return \DateInterval
*/
abstract protected function wait_period(\core_analytics\analysable $analysable);
/**
* Returns whether the course can be processed by this time splitting method or not.
*
* @param \core_analytics\analysable $analysable
* @return bool
*/
public function is_valid_analysable(\core_analytics\analysable $analysable) {
if (!$analysable->get_start()) {
return false;
}
$predictionstart = $this->get_prediction_interval_start($analysable);
if ($analysable->get_start() > $predictionstart) {
// We still need to wait.
return false;
}
return true;
}
/**
* This time-splitting method returns one single range, the start to two days before the end.
*
* @return array The list of ranges, each of them including 'start', 'end' and 'time'
*/
protected function define_ranges() {
$now = time();
$ranges = [
[
'start' => $this->analysable->get_start(),
'end' => $now,
'time' => $now,
]
];
return $ranges;
}
/**
* Whether to cache or not the indicator calculations.
*
* @return bool
*/
public function cache_indicator_calculations(): bool {
return false;
}
/**
* Calculates the interval start time backwards, from now.
*
* @param \core_analytics\analysable $analysable
* @return int
*/
protected function get_prediction_interval_start(\core_analytics\analysable $analysable) {
// The prediction time is always time(). We don't want to reuse the firstanalysis time
// because otherwise samples (e.g. students) which start after the analysable (e.g. course)
// start would use an incorrect analysis interval.
$predictionstart = new \DateTime('now');
$predictionstart->sub($this->wait_period($analysable));
return $predictionstart->getTimestamp();
}
}
@@ -0,0 +1,311 @@
<?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/>.
/**
* Base time splitting method.
*
* @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\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Base time splitting method.
*
* @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
*/
abstract class base {
/**
* @var string
*/
protected $id;
/**
* The model id.
*
* @var int
*/
protected $modelid;
/**
* @var \core_analytics\analysable
*/
protected $analysable;
/**
* @var array
*/
protected $ranges = [];
/**
* Define the time splitting methods ranges.
*
* 'time' value defines when predictions are executed, their values will be compared with
* the current time in ready_to_predict. The ranges should be sorted by 'time' in
* ascending order.
*
* @return array('start' => time(), 'end' => time(), 'time' => time())
*/
abstract protected function define_ranges();
/**
* Returns a lang_string object representing the name for the time splitting method.
*
* Used as column identificator.
*
* If there is a corresponding '_help' string this will be shown as well.
*
* @return \lang_string
*/
abstract public static function get_name(): \lang_string;
/**
* Returns the time splitting method id.
*
* @return string
*/
public function get_id() {
return '\\' . get_class($this);
}
/**
* Assigns the analysable and updates the time ranges according to the analysable start and end dates.
*
* @param \core_analytics\analysable $analysable
* @return void
*/
public function set_analysable(\core_analytics\analysable $analysable) {
$this->analysable = $analysable;
$this->ranges = $this->define_ranges();
$this->validate_ranges();
}
/**
* Assigns the model id to this time-splitting method it case it needs it.
*
* @param int $modelid
*/
public function set_modelid(int $modelid) {
$this->modelid = $modelid;
}
/**
* get_analysable
*
* @return \core_analytics\analysable
*/
public function get_analysable() {
return $this->analysable;
}
/**
* Returns whether the course can be processed by this time splitting method or not.
*
* @param \core_analytics\analysable $analysable
* @return bool
*/
public function is_valid_analysable(\core_analytics\analysable $analysable) {
return true;
}
/**
* Should we predict this time range now?
*
* @param array $range
* @return bool
*/
public function ready_to_predict($range) {
if ($range['time'] <= time()) {
return true;
}
return false;
}
/**
* Should we use this time range for training?
*
* @param array $range
* @return bool
*/
public function ready_to_train($range) {
$now = time();
if ($range['time'] <= $now && $range['end'] <= $now) {
return true;
}
return false;
}
/**
* Returns the ranges used by this time splitting method.
*
* @return array
*/
public function get_all_ranges() {
return $this->ranges;
}
/**
* By default all ranges are for training.
*
* @return array
*/
public function get_training_ranges() {
return $this->ranges;
}
/**
* Returns the distinct range indexes in this time splitting method.
*
* @return int[]
*/
public function get_distinct_ranges() {
if ($this->include_range_info_in_training_data()) {
return array_keys($this->ranges);
} else {
return [0];
}
}
/**
* Returns the most recent range that can be used to predict.
*
* This method is only called when calculating predictions.
*
* @return array
*/
public function get_most_recent_prediction_range() {
$ranges = $this->get_all_ranges();
// Opposite order as we are interested in the last range that can be used for prediction.
krsort($ranges);
// We already provided the analysable to the time splitting method, there is no need to feed it back.
foreach ($ranges as $rangeindex => $range) {
if ($this->ready_to_predict($range)) {
// We need to maintain the same indexes.
return array($rangeindex => $range);
}
}
return array();
}
/**
* Returns range data by its index.
*
* @param int $rangeindex
* @return array|false Range data or false if the index is not part of the existing ranges.
*/
public function get_range_by_index($rangeindex) {
if (!isset($this->ranges[$rangeindex])) {
return false;
}
return $this->ranges[$rangeindex];
}
/**
* Generates a unique sample id (sample in a range index).
*
* @param int $sampleid
* @param int $rangeindex
* @return string
*/
final public function append_rangeindex($sampleid, $rangeindex) {
return $sampleid . '-' . $rangeindex;
}
/**
* Returns the sample id and the range index from a uniquesampleid.
*
* @param string $uniquesampleid
* @return array array($sampleid, $rangeindex)
*/
final public function infer_sample_info($uniquesampleid) {
return explode('-', $uniquesampleid);
}
/**
* Whether to include the range index in the training data or not.
*
* By default, we consider that the different time ranges included in a time splitting method may not be
* compatible between them (i.e. the indicators calculated at the end of the course can easily
* differ from indicators calculated at the beginning of the course). So we include the range index as
* one of the variables that the machine learning backend uses to generate predictions.
*
* If the indicators calculated using the different time ranges available in this time splitting method
* are comparable you can overwrite this method to return false.
*
* Note that:
* - This is only relevant for models whose predictions are not based on assumptions
* (i.e. the ones using a machine learning backend to generate predictions).
* - The ranges can only be included in the training data when
* we know the final number of ranges the time splitting method will have. E.g.
* We can not know the final number of ranges of a 'daily' time splitting method
* as we will have one new range every day.
* @return bool
*/
public function include_range_info_in_training_data() {
return true;
}
/**
* Whether to cache or not the indicator calculations.
*
* Indicator calculations are stored to be reused across models. The calculations
* are indexed by the calculation start and end time, and these times depend on the
* time-splitting method. You should overwrite this method and return false if the time
* frames generated by your time-splitting method are unique and / or can hardly be
* reused by further models.
*
* @return bool
*/
public function cache_indicator_calculations(): bool {
return true;
}
/**
* Is this method valid to evaluate prediction models?
*
* @return bool
*/
public function valid_for_evaluation(): bool {
return true;
}
/**
* Validates the time splitting method ranges.
*
* @throws \coding_exception
* @return void
*/
protected function validate_ranges() {
foreach ($this->ranges as $key => $range) {
if (!isset($this->ranges[$key]['start']) || !isset($this->ranges[$key]['end']) ||
!isset($this->ranges[$key]['time'])) {
throw new \coding_exception($this->get_id() . ' time splitting method "' . $key .
'" range is not fully defined. We need a start timestamp and an end timestamp.');
}
}
}
}
@@ -0,0 +1,37 @@
<?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/>.
/**
* Interface for time-splitting methods whose ranges' times are before time().
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Interface for time-splitting methods whose ranges' times are before time().
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface before_now {
}
@@ -0,0 +1,88 @@
<?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/>.
/**
* X parts time splitting method.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* X parts time splitting method.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class equal_parts extends base implements before_now {
/**
* Returns the number of parts the analyser duration should be split in.
*
* @return int
*/
abstract protected function get_number_parts();
/**
* Splits the analysable duration in X equal parts from the start to the end.
*
* @return array
*/
protected function define_ranges() {
$nparts = $this->get_number_parts();
$rangeduration = ($this->analysable->get_end() - $this->analysable->get_start()) / $nparts;
if ($rangeduration < $nparts) {
// It is interesting to avoid having a single timestamp belonging to multiple time ranges
// because of things like community of inquiry indicators, where activities have a due date
// that, ideally, would fall only into 1 time range. If the analysable duration is very short
// it is because the model doesn't contain indicators that depend so heavily on time and therefore
// we don't need to worry about timestamps being present in multiple time ranges.
$allowmultipleranges = true;
}
$ranges = array();
for ($i = 0; $i < $nparts; $i++) {
$start = $this->analysable->get_start() + intval($rangeduration * $i);
$end = $this->analysable->get_start() + intval($rangeduration * ($i + 1));
if (empty($allowmultipleranges) && $i > 0 && $start === $ranges[$i - 1]['end']) {
// We add 1 second so each timestamp only belongs to 1 range.
$start = $start + 1;
}
if ($i === ($nparts - 1)) {
// Better to use the end for the last one as we are using floor above.
$end = $this->analysable->get_end();
}
$ranges[$i] = array(
'start' => $start,
'end' => $end,
'time' => $end
);
}
return $ranges;
}
}
@@ -0,0 +1,97 @@
<?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/>.
/**
* Time splitting method that generates predictions regularly.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Time splitting method that generates predictions periodically.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class past_periodic extends periodic implements before_now {
/**
* Gets the next range with start on the provided time.
*
* The next range is based on the past period so we substract this
* range's periodicity from $time.
*
* @param \DateTimeImmutable $time
* @return array
*/
protected function get_next_range(\DateTimeImmutable $time) {
$end = $time->getTimestamp();
$start = $time->sub($this->periodicity())->getTimestamp();
if ($start < $this->analysable->get_start()) {
// We skip the first range generated as its start is prior to the analysable start.
return false;
}
return [
'start' => $start,
'end' => $end,
'time' => $end
];
}
/**
* Get the start of the first time range.
*
* @return int A timestamp.
*/
protected function get_first_start() {
return $this->analysable->get_start();
}
/**
* Guarantees that the last range dates end right now.
*
* @param array $ranges
* @return array
*/
protected function update_last_range(array $ranges) {
$lastrange = end($ranges);
if ($lastrange['time'] > time()) {
// We just need to wait in this case.
return $lastrange;
}
$timetoenddiff = time() - $lastrange['time'];
$ranges[count($ranges) - 1] = [
'start' => $lastrange['start'] + $timetoenddiff,
'end' => $lastrange['end'] + $timetoenddiff,
'time' => $lastrange['time'] + $timetoenddiff,
];
return $ranges;
}
}
@@ -0,0 +1,162 @@
<?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/>.
/**
* Time splitting method that generates predictions regularly.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Time splitting method that generates predictions periodically.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class periodic extends base {
/**
* The periodicity of the predictions / training data generation.
*
* @return \DateInterval
*/
abstract protected function periodicity();
/**
* Gets the next range with start on the provided time.
*
* @param \DateTimeImmutable $time
* @return array
*/
abstract protected function get_next_range(\DateTimeImmutable $time);
/**
* Get the start of the first time range.
*
* @return int A timestamp.
*/
abstract protected function get_first_start();
/**
* Returns whether the analysable can be processed by this time splitting method or not.
*
* @param \core_analytics\analysable $analysable
* @return bool
*/
public function is_valid_analysable(\core_analytics\analysable $analysable) {
if (!$analysable->get_start()) {
return false;
}
return true;
}
/**
* define_ranges
*
* @return array
*/
protected function define_ranges() {
$periodicity = $this->periodicity();
if ($this->analysable->get_end()) {
$end = (new \DateTimeImmutable())->setTimestamp($this->analysable->get_end());
}
$nexttime = (new \DateTimeImmutable())->setTimestamp($this->get_first_start());
$now = new \DateTimeImmutable('now', \core_date::get_server_timezone_object());
$range = $this->get_next_range($nexttime);
if (!$range) {
$nexttime = $nexttime->add($periodicity);
$range = $this->get_next_range($nexttime);
if (!$range) {
throw new \coding_exception('The get_next_range implementation is broken. The difference between two consecutive
ranges can not be more than the periodicity.');
}
}
$ranges = [];
$endreached = false;
while (($this->ready_to_predict($range) || $this->ready_to_train($range)) && !$endreached) {
$ranges[] = $range;
$nexttime = $nexttime->add($periodicity);
$range = $this->get_next_range($nexttime);
$endreached = (!empty($end) && $nexttime > $end);
}
if ($ranges && !$endreached) {
// If this analysable is not finished we adjust the start and end of the last element in $ranges
// so that it ends in time().The reason is that the start of these ranges is based on the analysable
// start and the end is calculated based on the start. This is to prevent the same issue we had in MDL-65348.
//
// An example of the situation we want to avoid is:
// A course started on a Monday, in 2015. It has no end date. Now the system is upgraded to Moodle 3.8, which
// includes this code. This happens on Wednesday. Periodic ranges (e.g. weekly) will be calculated from a Monday
// so the data provided by the time-splitting method would be from Monday to Monday, when we really want to
// provide data from Wednesday to the past Wednesday.
$ranges = $this->update_last_range($ranges);
}
return $ranges;
}
/**
* Overwritten as all generated rows are comparable.
*
* @return bool
*/
public function include_range_info_in_training_data() {
return false;
}
/**
* Overwritting as the last range may be for prediction.
*
* @return array
*/
public function get_training_ranges() {
// Cloning the array.
$trainingranges = $this->ranges;
foreach ($trainingranges as $rangeindex => $range) {
if (!$this->ready_to_train($range)) {
unset($trainingranges[$rangeindex]);
}
}
return $trainingranges;
}
/**
* Allows child classes to update the last range provided.
*
* @param array $ranges
* @return array
*/
protected function update_last_range(array $ranges) {
return $ranges;
}
}
@@ -0,0 +1,96 @@
<?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/>.
/**
* Time splitting method that generates predictions periodically.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\local\time_splitting;
defined('MOODLE_INTERNAL') || die();
/**
* Time splitting method that generates predictions periodically.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class upcoming_periodic extends periodic implements after_now {
/**
* Gets the next range with start on the provided time.
*
* The next range is based on the upcoming period so we add this
* range's periodicity to $time.
*
* @param \DateTimeImmutable $time
* @return array
*/
protected function get_next_range(\DateTimeImmutable $time) {
$start = $time->getTimestamp();
$end = $time->add($this->periodicity())->getTimestamp();
return [
'start' => $start,
'end' => $end,
'time' => $start
];
}
/**
* Whether to cache or not the indicator calculations.
* @return bool
*/
public function cache_indicator_calculations(): bool {
return false;
}
/**
* Overriden as these time-splitting methods are based on future dates.
*
* @return bool
*/
public function valid_for_evaluation(): bool {
return false;
}
/**
* Get the start of the first time range.
*
* Overwriten to start generating predictions about upcoming stuff from time().
*
* @return int A timestamp.
*/
protected function get_first_start() {
global $DB;
$cache = \cache::make('core', 'modelfirstanalyses');
$key = $this->modelid . '_' . $this->analysable->get_id();
$firstanalysis = $cache->get($key);
if (!empty($firstanalysis)) {
return $firstanalysis;
}
// This analysable has not yet been analysed, the start is therefore now.
return time();
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
<?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/>.
/**
* Model configuration manager.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Model configuration manager.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class model_config {
/**
* @var \core_analytics\model
*/
private $model = null;
/**
* The name of the file where config is held.
*/
const CONFIG_FILE_NAME = 'model-config.json';
/**
* Constructor.
*
* @param \core_analytics\model|null $model
*/
public function __construct(?model $model = null) {
$this->model = $model;
}
/**
* Exports a model to a zip using the provided file name.
*
* @param string $zipfilename
* @param bool $includeweights Include the model weights if available
* @return string
*/
public function export(string $zipfilename, bool $includeweights = true): string {
if (!$this->model) {
throw new \coding_exception('No model object provided.');
}
if (!$this->model->can_export_configuration()) {
throw new \moodle_exception('errornoexportconfigrequirements', 'analytics');
}
$zip = new \zip_packer();
$zipfiles = [];
// Model config in JSON.
$modeldata = $this->export_model_data();
$exporttmpdir = make_request_directory();
$jsonfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . 'model-config.json';
if (!file_put_contents($jsonfilepath, json_encode($modeldata))) {
throw new \moodle_exception('errornoexportconfig', 'analytics');
}
$zipfiles[self::CONFIG_FILE_NAME] = $jsonfilepath;
// ML backend.
if ($includeweights && $this->model->is_trained()) {
$processor = $this->model->get_predictions_processor(true);
$outputdir = $this->model->get_output_dir(array('execution'));
$mlbackenddir = $processor->export($this->model->get_unique_id(), $outputdir);
$mlbackendfiles = get_directory_list($mlbackenddir);
foreach ($mlbackendfiles as $mlbackendfile) {
$fullpath = $mlbackenddir . DIRECTORY_SEPARATOR . $mlbackendfile;
// Place the ML backend files inside a mlbackend/ dir.
$zipfiles['mlbackend/' . $mlbackendfile] = $fullpath;
}
}
$zipfilepath = $exporttmpdir . DIRECTORY_SEPARATOR . $zipfilename;
$zip->archive_to_pathname($zipfiles, $zipfilepath);
return $zipfilepath;
}
/**
* Imports the provided model configuration into a new model.
*
* Note that this method assumes that self::check_dependencies has already been called.
*
* @param string $zipfilepath Path to the zip file to import
* @return \core_analytics\model
*/
public function import(string $zipfilepath): \core_analytics\model {
list($modeldata, $mlbackenddir) = $this->extract_import_contents($zipfilepath);
$target = \core_analytics\manager::get_target($modeldata->target);
$indicators = [];
foreach ($modeldata->indicators as $indicatorclass) {
$indicator = \core_analytics\manager::get_indicator($indicatorclass);
$indicators[$indicator->get_id()] = $indicator;
}
$model = \core_analytics\model::create($target, $indicators, $modeldata->timesplitting, $modeldata->processor);
// Import them disabled.
$model->update(false, false, false, false);
if ($mlbackenddir) {
$modeldir = $model->get_output_dir(['execution']);
if (!$model->get_predictions_processor(true)->import($model->get_unique_id(), $modeldir, $mlbackenddir)) {
throw new \moodle_exception('errorimport', 'analytics');
}
$model->mark_as_trained();
}
return $model;
}
/**
* Check that the provided model configuration can be deployed in this site.
*
* @param \stdClass $modeldata
* @param bool $ignoreversionmismatches
* @return string|null Error string or null if all good.
*/
public function check_dependencies(\stdClass $modeldata, bool $ignoreversionmismatches): ?string {
$siteversions = \core_component::get_all_versions();
// Possible issues.
$missingcomponents = [];
$versionmismatches = [];
$missingclasses = [];
// We first check that this site has the required dependencies and the required versions.
foreach ($modeldata->dependencies as $component => $importversion) {
if (empty($siteversions[$component])) {
if ($component === 'core') {
$component = 'Moodle';
}
$missingcomponents[$component] = $component . ' (' . $importversion . ')';
continue;
}
if ($siteversions[$component] == $importversion) {
// All good here.
continue;
}
if (!$ignoreversionmismatches) {
if ($component === 'core') {
$component = 'Moodle';
}
$versionmismatches[$component] = $component . ' (' . $importversion . ')';
}
}
// Checking that each of the components is available.
if (!$target = manager::get_target($modeldata->target)) {
$missingclasses[] = $modeldata->target;
}
if (!$timesplitting = manager::get_time_splitting($modeldata->timesplitting)) {
$missingclasses[] = $modeldata->timesplitting;
}
// Indicators.
foreach ($modeldata->indicators as $indicatorclass) {
if (!$indicator = manager::get_indicator($indicatorclass)) {
$missingclasses[] = $indicatorclass;
}
}
// ML backend.
if (!empty($modeldata->processor)) {
if (!$processor = \core_analytics\manager::get_predictions_processor($modeldata->processor, false)) {
$missingclasses[] = $indicatorclass;
}
}
if (!empty($missingcomponents)) {
return get_string('errorimportmissingcomponents', 'analytics', join(', ', $missingcomponents));
}
if (!empty($versionmismatches)) {
return get_string('errorimportversionmismatches', 'analytics', implode(', ', $versionmismatches));
}
if (!empty($missingclasses)) {
$a = (object)[
'missingclasses' => implode(', ', $missingclasses),
];
return get_string('errorimportmissingclasses', 'analytics', $a);
}
// No issues found.
return null;
}
/**
* Returns the component the class belongs to.
*
* Note that this method does not work for global space classes.
*
* @param string $fullclassname Qualified name including the namespace.
* @return string|null Frankenstyle component
*/
public static function get_class_component(string $fullclassname): ?string {
// Strip out leading backslash.
$fullclassname = ltrim($fullclassname, '\\');
$nextbackslash = strpos($fullclassname, '\\');
if ($nextbackslash === false) {
// Global space.
return 'core';
}
$component = substr($fullclassname, 0, $nextbackslash);
// All core subsystems use core's version.php.
if (strpos($component, 'core_') === 0) {
$component = 'core';
}
return $component;
}
/**
* Extracts the import zip contents.
*
* @param string $zipfilepath Zip file path
* @return array [0] => \stdClass, [1] => string
*/
public function extract_import_contents(string $zipfilepath): array {
$importtempdir = make_request_directory();
$zip = new \zip_packer();
$filelist = $zip->extract_to_pathname($zipfilepath, $importtempdir);
if (empty($filelist[self::CONFIG_FILE_NAME])) {
// Missing required file.
throw new \moodle_exception('errorimport', 'analytics');
}
$jsonmodeldata = file_get_contents($importtempdir . DIRECTORY_SEPARATOR . self::CONFIG_FILE_NAME);
if (!$modeldata = json_decode($jsonmodeldata)) {
throw new \moodle_exception('errorimport', 'analytics');
}
if (empty($modeldata->target) || empty($modeldata->timesplitting) || empty($modeldata->indicators)) {
throw new \moodle_exception('errorimport', 'analytics');
}
$mlbackenddir = $importtempdir . DIRECTORY_SEPARATOR . 'mlbackend';
if (!is_dir($mlbackenddir)) {
$mlbackenddir = false;
}
return [$modeldata, $mlbackenddir];
}
/**
* Exports the configuration of the model.
* @return \stdClass
*/
protected function export_model_data(): \stdClass {
$versions = \core_component::get_all_versions();
$data = new \stdClass();
// Target.
$data->target = $this->model->get_target()->get_id();
$requiredclasses[] = $data->target;
// Time splitting method.
$data->timesplitting = $this->model->get_time_splitting()->get_id();
$requiredclasses[] = $data->timesplitting;
// Model indicators.
$data->indicators = [];
foreach ($this->model->get_indicators() as $indicator) {
$indicatorid = $indicator->get_id();
$data->indicators[] = $indicatorid;
$requiredclasses[] = $indicatorid;
}
// Return the predictions processor this model is using, even if no predictions processor
// was explicitly selected.
$predictionsprocessor = $this->model->get_predictions_processor();
$data->processor = '\\' . get_class($predictionsprocessor);
$requiredclasses[] = $data->processor;
// Add information for versioning.
$data->dependencies = [];
foreach ($requiredclasses as $fullclassname) {
$component = $this->get_class_component($fullclassname);
$data->dependencies[$component] = $versions[$component];
}
return $data;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Exportable machine learning backend interface.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Exportable machine learning backend interface.
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface packable {
/**
* Exports the machine learning model.
*
* @throws \moodle_exception
* @param string $uniqueid The model unique id
* @param string $modeldir The directory that contains the trained model.
* @return string The path to the directory that contains the exported model.
*/
public function export(string $uniqueid, string $modeldir): string;
/**
* Imports the provided machine learning model.
*
* @param string $uniqueid The model unique id
* @param string $modeldir The directory that will contain the trained model.
* @param string $importdir The directory that contains the files to import.
* @return bool Success
*/
public function import(string $uniqueid, string $modeldir, string $importdir): bool;
}
+255
View File
@@ -0,0 +1,255 @@
<?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/>.
/**
* Representation of a prediction.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Representation of a prediction.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class prediction {
/**
* Prediction details (one of the default prediction actions)
*/
const ACTION_PREDICTION_DETAILS = 'predictiondetails';
/**
* Prediction useful (one of the default prediction actions)
*/
const ACTION_USEFUL = 'useful';
/**
* Prediction not useful (one of the default prediction actions)
*/
const ACTION_NOT_USEFUL = 'notuseful';
/**
* Prediction already fixed (one of the default prediction actions)
*/
const ACTION_FIXED = 'fixed';
/**
* Prediction not applicable.
*/
const ACTION_NOT_APPLICABLE = 'notapplicable';
/**
* Prediction incorrectly flagged.
*/
const ACTION_INCORRECTLY_FLAGGED = 'incorrectlyflagged';
/**
* @var \stdClass
*/
private $prediction;
/**
* @var array
*/
private $sampledata;
/**
* @var array
*/
private $calculations = array();
/**
* Constructor
*
* @param \stdClass|int $prediction
* @param array $sampledata
* @return void
*/
public function __construct($prediction, $sampledata) {
global $DB;
if (is_scalar($prediction)) {
$prediction = $DB->get_record('analytics_predictions', array('id' => $prediction), '*', MUST_EXIST);
}
$this->prediction = $prediction;
$this->sampledata = $sampledata;
$this->format_calculations();
}
/**
* Get prediction object data.
*
* @return \stdClass
*/
public function get_prediction_data() {
return $this->prediction;
}
/**
* Get prediction sample data.
*
* @return array
*/
public function get_sample_data() {
return $this->sampledata;
}
/**
* Gets the prediction calculations
*
* @return array
*/
public function get_calculations() {
return $this->calculations;
}
/**
* Stores the executed action.
* Prediction instances should be retrieved using \core_analytics\manager::get_prediction,
* It is the caller responsability to check that the user can see the prediction.
*
* @param string $actionname
* @param \core_analytics\local\target\base $target
*/
public function action_executed($actionname, \core_analytics\local\target\base $target) {
global $USER, $DB;
$context = \context::instance_by_id($this->get_prediction_data()->contextid, IGNORE_MISSING);
if (!$context) {
throw new \moodle_exception('errorpredictioncontextnotavailable', 'analytics');
}
// Check that the provided action exists.
$actions = $target->prediction_actions($this, true);
foreach ($actions as $action) {
if ($action->get_action_name() === $actionname) {
$found = true;
}
}
$bulkactions = $target->bulk_actions([$this]);
foreach ($bulkactions as $action) {
if ($action->get_action_name() === $actionname) {
$found = true;
}
}
if (empty($found)) {
throw new \moodle_exception('errorunknownaction', 'analytics');
}
$predictionid = $this->get_prediction_data()->id;
$action = new \stdClass();
$action->predictionid = $predictionid;
$action->userid = $USER->id;
$action->actionname = $actionname;
$action->timecreated = time();
$DB->insert_record('analytics_prediction_actions', $action);
$eventdata = array (
'context' => $context,
'objectid' => $predictionid,
'other' => array('actionname' => $actionname)
);
\core\event\prediction_action_started::create($eventdata)->trigger();
}
/**
* Get the executed actions.
*
* Actions could be filtered by actionname.
*
* @param array $actionnamefilter Limit the results obtained to this list of action names.
* @param int $userid the user id. Current user by default.
* @return array of actions.
*/
public function get_executed_actions(array $actionnamefilter = null, int $userid = 0): array {
global $USER, $DB;
$conditions[] = "predictionid = :predictionid";
$params['predictionid'] = $this->get_prediction_data()->id;
if (!$userid) {
$userid = $USER->id;
}
$conditions[] = "userid = :userid";
$params['userid'] = $userid;
if ($actionnamefilter) {
list($actionsql, $actionparams) = $DB->get_in_or_equal($actionnamefilter, SQL_PARAMS_NAMED);
$conditions[] = "actionname $actionsql";
$params = $params + $actionparams;
}
return $DB->get_records_select('analytics_prediction_actions', implode(' AND ', $conditions), $params);
}
/**
* format_calculations
*
* @return \stdClass[]
*/
private function format_calculations() {
$calculations = json_decode($this->prediction->calculations, true);
foreach ($calculations as $featurename => $value) {
list($indicatorclass, $subtype) = $this->parse_feature_name($featurename);
if ($indicatorclass === 'range') {
// Time range indicators don't belong to any indicator class, we don't store them.
continue;
} else if (!\core_analytics\manager::is_valid($indicatorclass, '\core_analytics\local\indicator\base')) {
throw new \moodle_exception('errorpredictionformat', 'analytics');
}
$this->calculations[$featurename] = new \stdClass();
$this->calculations[$featurename]->subtype = $subtype;
$this->calculations[$featurename]->indicator = \core_analytics\manager::get_indicator($indicatorclass);
$this->calculations[$featurename]->value = $value;
}
}
/**
* parse_feature_name
*
* @param string $featurename
* @return string[]
*/
private function parse_feature_name($featurename) {
$indicatorclass = $featurename;
$subtype = false;
// Some indicator result in more than 1 feature, we need to see which feature are we dealing with.
$separatorpos = strpos($featurename, '/');
if ($separatorpos !== false) {
$subtype = substr($featurename, ($separatorpos + 1));
$indicatorclass = substr($featurename, 0, $separatorpos);
}
return array($indicatorclass, $subtype);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?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/>.
/**
* Representation of a suggested action associated with a prediction.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Representation of a suggested action associated with a prediction.
*
* @package core_analytics
* @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class prediction_action extends action {
/**
* Prediction action constructor.
*
* @param string $actionname They should match a-zA-Z_0-9-, as we apply a PARAM_ALPHANUMEXT filter
* @param \core_analytics\prediction $prediction
* @param \moodle_url $actionurl The final URL where the user should be forwarded.
* @param \pix_icon $icon Link icon
* @param string $text Link text
* @param bool $primary Primary button or secondary.
* @param array $attributes Link attributes
* @param string|false $type
* @return void
*/
public function __construct($actionname, \core_analytics\prediction $prediction, \moodle_url $actionurl, \pix_icon $icon,
$text, $primary = false, $attributes = array(), $type = false) {
$this->actionname = $actionname;
$this->text = $text;
$this->set_type($type);
$this->url = self::transform_to_forward_url($actionurl, $actionname, $prediction->get_prediction_data()->id);
// The \action_menu_link items are displayed as an icon with a label, no need to show any text.
if ($primary === false) {
$this->actionlink = new \action_menu_link_secondary($this->url, $icon, '', $attributes);
} else {
$this->actionlink = new \action_menu_link_primary($this->url, $icon, '', $attributes);
}
}
/**
* Transforms the provided url to an action url so we can record the user actions.
*
* Note that it is the caller responsibility to check that the provided actionname is valid for the prediction target.
*
* @param \moodle_url $actionurl
* @param string $actionname
* @param int $predictionid
* @return \moodle_url
*/
public static function transform_to_forward_url(\moodle_url $actionurl, string $actionname, int $predictionid): \moodle_url {
// We want to track how effective are our suggested actions, we pass users through a script that will log these actions.
$params = ['action' => $actionname, 'predictionid' => $predictionid,
'forwardurl' => $actionurl->out(false)];
return new \moodle_url('/report/insights/action.php', $params);
}
}
+78
View File
@@ -0,0 +1,78 @@
<?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/>.
/**
* Predictions processor interface.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Predictors interface.
*
* @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
*/
interface predictor {
/**
* Is it ready to predict?
*
* @return bool
*/
public function is_ready();
/**
* Delete all stored information of the current model id.
*
* This method is called when there are important changes to a model,
* all previous training algorithms using that version of the model
* should be deleted.
*
* In case you want to perform extra security measures before deleting
* a directory you can check that $modelversionoutputdir subdirectories
* can only be named 'execution', 'evaluation' or 'testing'.
*
* @param string $uniqueid The site model unique id string
* @param string $modelversionoutputdir The output dir of this model version
* @return null
*/
public function clear_model($uniqueid, $modelversionoutputdir);
/**
* Delete the output directory.
*
* This method is called when a model is completely deleted.
*
* In case you want to perform extra security measures before deleting
* a directory you can check that the subdirectories are timestamps
* (the model version) and each of this subdirectories' subdirectories
* can only be named 'execution', 'evaluation' or 'testing'.
*
* @param string $modeloutputdir The model directory id (parent of all model versions subdirectories).
* @param string $uniqueid
* @return null
*/
public function delete_output_dir($modeloutputdir, $uniqueid);
}
+524
View File
@@ -0,0 +1,524 @@
<?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/>.
/**
* Privacy Subsystem implementation for core_analytics.
*
* @package core_analytics
* @copyright 2018 David Monllaó
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics\privacy;
use core_privacy\local\request\transform;
use core_privacy\local\request\writer;
use core_privacy\local\metadata\collection;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\approved_userlist;
use core_privacy\local\request\context;
use core_privacy\local\request\contextlist;
use core_privacy\local\request\userlist;
defined('MOODLE_INTERNAL') || die();
/**
* Privacy Subsystem for core_analytics implementing metadata and plugin providers.
*
* @copyright 2018 David Monllaó
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements
\core_privacy\local\metadata\provider,
\core_privacy\local\request\core_userlist_provider,
\core_privacy\local\request\plugin\provider {
/**
* Returns meta data about this system.
*
* @param collection $collection The initialised collection to add items to.
* @return collection A listing of user data stored through this system.
*/
public static function get_metadata(collection $collection): collection {
$collection->add_database_table(
'analytics_indicator_calc',
[
'starttime' => 'privacy:metadata:analytics:indicatorcalc:starttime',
'endtime' => 'privacy:metadata:analytics:indicatorcalc:endtime',
'contextid' => 'privacy:metadata:analytics:indicatorcalc:contextid',
'sampleorigin' => 'privacy:metadata:analytics:indicatorcalc:sampleorigin',
'sampleid' => 'privacy:metadata:analytics:indicatorcalc:sampleid',
'indicator' => 'privacy:metadata:analytics:indicatorcalc:indicator',
'value' => 'privacy:metadata:analytics:indicatorcalc:value',
'timecreated' => 'privacy:metadata:analytics:indicatorcalc:timecreated',
],
'privacy:metadata:analytics:indicatorcalc'
);
$collection->add_database_table(
'analytics_predictions',
[
'modelid' => 'privacy:metadata:analytics:predictions:modelid',
'contextid' => 'privacy:metadata:analytics:predictions:contextid',
'sampleid' => 'privacy:metadata:analytics:predictions:sampleid',
'rangeindex' => 'privacy:metadata:analytics:predictions:rangeindex',
'prediction' => 'privacy:metadata:analytics:predictions:prediction',
'predictionscore' => 'privacy:metadata:analytics:predictions:predictionscore',
'calculations' => 'privacy:metadata:analytics:predictions:calculations',
'timecreated' => 'privacy:metadata:analytics:predictions:timecreated',
'timestart' => 'privacy:metadata:analytics:predictions:timestart',
'timeend' => 'privacy:metadata:analytics:predictions:timeend',
],
'privacy:metadata:analytics:predictions'
);
$collection->add_database_table(
'analytics_prediction_actions',
[
'predictionid' => 'privacy:metadata:analytics:predictionactions:predictionid',
'userid' => 'privacy:metadata:analytics:predictionactions:userid',
'actionname' => 'privacy:metadata:analytics:predictionactions:actionname',
'timecreated' => 'privacy:metadata:analytics:predictionactions:timecreated',
],
'privacy:metadata:analytics:predictionactions'
);
// Regarding this block, we are unable to export or purge this data, as
// it would damage the analytics data across the whole site.
$collection->add_database_table(
'analytics_models',
[
'usermodified' => 'privacy:metadata:analytics:analyticsmodels:usermodified',
],
'privacy:metadata:analytics:analyticsmodels'
);
// Regarding this block, we are unable to export or purge this data, as
// it would damage the analytics log data across the whole site.
$collection->add_database_table(
'analytics_models_log',
[
'usermodified' => 'privacy:metadata:analytics:analyticsmodelslog:usermodified',
],
'privacy:metadata:analytics:analyticsmodelslog'
);
return $collection;
}
/**
* Get the list of contexts that contain user information for the specified user.
*
* @param int $userid The user to search.
* @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
*/
public static function get_contexts_for_userid(int $userid): contextlist {
global $DB;
$contextlist = new \core_privacy\local\request\contextlist();
$models = self::get_models_with_user_data();
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT DISTINCT ap.contextid FROM {analytics_predictions} ap
{$joinusersql}
WHERE u.id = :userid AND ap.modelid = :modelid";
$contextlist->add_from_sql($sql, ['userid' => $userid, 'modelid' => $modelid]);
// Indicator calculations.
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT DISTINCT aic.contextid FROM {analytics_indicator_calc} aic
{$joinusersql}
WHERE u.id = :userid AND aic.sampleorigin = :analysersamplesorigin";
$contextlist->add_from_sql($sql, ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()]);
}
// We can leave this out of the loop as there is no analyser-dependent stuff.
list($sql, $params) = self::analytics_prediction_actions_user_sql($userid, array_keys($models));
$sql = "SELECT DISTINCT ap.contextid" . $sql;
$contextlist->add_from_sql($sql, $params);
return $contextlist;
}
/**
* Get the list of users who have data within a context.
*
* @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
*/
public static function get_users_in_context(userlist $userlist) {
global $DB;
$context = $userlist->get_context();
$models = self::get_models_with_user_data();
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$params = [
'contextid' => $context->id,
'modelid' => $modelid,
];
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT u.id AS userid
FROM {analytics_predictions} ap
{$joinusersql}
WHERE ap.contextid = :contextid
AND ap.modelid = :modelid";
$userlist->add_from_sql('userid', $sql, $params);
// Indicator calculations.
$params = [
'contextid' => $context->id,
'analysersamplesorigin' => $analyser->get_samples_origin(),
];
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT u.id AS userid
FROM {analytics_indicator_calc} aic
{$joinusersql}
WHERE aic.contextid = :contextid
AND aic.sampleorigin = :analysersamplesorigin";
$userlist->add_from_sql('userid', $sql, $params);
}
// We can leave this out of the loop as there is no analyser-dependent stuff.
list($sql, $params) = self::analytics_prediction_actions_context_sql($context->id, array_keys($models));
$sql = "SELECT apa.userid" . $sql;
$userlist->add_from_sql('userid', $sql, $params);
}
/**
* Export all user data for the specified user, in the specified contexts.
*
* @param approved_contextlist $contextlist The approved contexts to export information for.
*/
public static function export_user_data(approved_contextlist $contextlist) {
global $DB;
$userid = intval($contextlist->get_user()->id);
$models = self::get_models_with_user_data();
$modelids = array_keys($models);
list ($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
$rootpath = [get_string('analytics', 'analytics')];
$ctxfields = \context_helper::get_preload_record_columns_sql('ctx');
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT ap.*, $ctxfields FROM {analytics_predictions} ap
JOIN {context} ctx ON ctx.id = ap.contextid
{$joinusersql}
WHERE u.id = :userid AND ap.modelid = :modelid AND ap.contextid {$contextsql}";
$params = ['userid' => $userid, 'modelid' => $modelid] + $contextparams;
$predictions = $DB->get_recordset_sql($sql, $params);
foreach ($predictions as $prediction) {
\context_helper::preload_from_record($prediction);
$context = \context::instance_by_id($prediction->contextid);
$path = $rootpath;
$path[] = get_string('privacy:metadata:analytics:predictions', 'analytics');
$path[] = $prediction->id;
$data = (object)[
'target' => $model->get_target()->get_name()->out(),
'context' => $context->get_context_name(true, true),
'prediction' => $model->get_target()->get_display_value($prediction->prediction),
'timestart' => transform::datetime($prediction->timestart),
'timeend' => transform::datetime($prediction->timeend),
'timecreated' => transform::datetime($prediction->timecreated),
];
writer::with_context($context)->export_data($path, $data);
}
$predictions->close();
// Indicator calculations.
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT aic.*, $ctxfields FROM {analytics_indicator_calc} aic
JOIN {context} ctx ON ctx.id = aic.contextid
{$joinusersql}
WHERE u.id = :userid AND aic.sampleorigin = :analysersamplesorigin AND aic.contextid {$contextsql}";
$params = ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()] + $contextparams;
$indicatorcalculations = $DB->get_recordset_sql($sql, $params);
foreach ($indicatorcalculations as $calculation) {
\context_helper::preload_from_record($calculation);
$context = \context::instance_by_id($calculation->contextid);
$path = $rootpath;
$path[] = get_string('privacy:metadata:analytics:indicatorcalc', 'analytics');
$path[] = $calculation->id;
$indicator = \core_analytics\manager::get_indicator($calculation->indicator);
$data = (object)[
'indicator' => $indicator::get_name()->out(),
'context' => $context->get_context_name(true, true),
'calculation' => $indicator->get_display_value($calculation->value),
'starttime' => transform::datetime($calculation->starttime),
'endtime' => transform::datetime($calculation->endtime),
'timecreated' => transform::datetime($calculation->timecreated),
];
writer::with_context($context)->export_data($path, $data);
}
$indicatorcalculations->close();
}
// Analytics predictions.
// Provided contexts are ignored as we export all user-related stuff.
list($sql, $params) = self::analytics_prediction_actions_user_sql($userid, $modelids, $contextsql);
$sql = "SELECT apa.*, ap.modelid, ap.contextid, $ctxfields" . $sql;
$predictionactions = $DB->get_recordset_sql($sql, $params + $contextparams);
foreach ($predictionactions as $predictionaction) {
\context_helper::preload_from_record($predictionaction);
$context = \context::instance_by_id($predictionaction->contextid);
$path = $rootpath;
$path[] = get_string('privacy:metadata:analytics:predictionactions', 'analytics');
$path[] = $predictionaction->id;
$data = (object)[
'target' => $models[$predictionaction->modelid]->get_target()->get_name()->out(),
'context' => $context->get_context_name(true, true),
'action' => $predictionaction->actionname,
'timecreated' => transform::datetime($predictionaction->timecreated),
];
writer::with_context($context)->export_data($path, $data);
}
$predictionactions->close();
}
/**
* Delete all data for all users in the specified context.
*
* @param context $context The specific context to delete data for.
*/
public static function delete_data_for_all_users_in_context(\context $context) {
global $DB;
$models = self::get_models_with_user_data();
$modelids = array_keys($models);
foreach ($models as $modelid => $model) {
$idssql = "SELECT ap.id FROM {analytics_predictions} ap
WHERE ap.contextid = :contextid AND ap.modelid = :modelid";
$idsparams = ['contextid' => $context->id, 'modelid' => $modelid];
$DB->delete_records_select('analytics_prediction_actions', "predictionid IN ($idssql)", $idsparams);
$DB->delete_records_select('analytics_predictions', "contextid = :contextid AND modelid = :modelid", $idsparams);
}
// We delete them all this table is just a cache and we don't know which model filled it.
$DB->delete_records('analytics_indicator_calc', ['contextid' => $context->id]);
}
/**
* Delete all user data for the specified user, in the specified contexts.
*
* @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
*/
public static function delete_data_for_user(approved_contextlist $contextlist) {
global $DB;
$userid = intval($contextlist->get_user()->id);
$models = self::get_models_with_user_data();
$modelids = array_keys($models);
list ($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
// Analytics prediction actions.
list($sql, $apaparams) = self::analytics_prediction_actions_user_sql($userid, $modelids, $contextsql);
$sql = "SELECT apa.id " . $sql;
$predictionactionids = $DB->get_fieldset_sql($sql, $apaparams + $contextparams);
if ($predictionactionids) {
list ($predictionactionidssql, $params) = $DB->get_in_or_equal($predictionactionids);
$DB->delete_records_select('analytics_prediction_actions', "id {$predictionactionidssql}", $params);
}
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT DISTINCT ap.id FROM {analytics_predictions} ap
{$joinusersql}
WHERE u.id = :userid AND ap.modelid = :modelid AND ap.contextid {$contextsql}";
$predictionids = $DB->get_fieldset_sql($sql, ['userid' => $userid, 'modelid' => $modelid] + $contextparams);
if ($predictionids) {
list($predictionidssql, $params) = $DB->get_in_or_equal($predictionids, SQL_PARAMS_NAMED);
$DB->delete_records_select('analytics_predictions', "id $predictionidssql", $params);
}
// Indicator calculations.
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT DISTINCT aic.id FROM {analytics_indicator_calc} aic
{$joinusersql}
WHERE u.id = :userid AND aic.sampleorigin = :analysersamplesorigin AND aic.contextid {$contextsql}";
$params = ['userid' => $userid, 'analysersamplesorigin' => $analyser->get_samples_origin()] + $contextparams;
$indicatorcalcids = $DB->get_fieldset_sql($sql, $params);
if ($indicatorcalcids) {
list ($indicatorcalcidssql, $params) = $DB->get_in_or_equal($indicatorcalcids, SQL_PARAMS_NAMED);
$DB->delete_records_select('analytics_indicator_calc', "id $indicatorcalcidssql", $params);
}
}
}
/**
* Delete multiple users within a single context.
*
* @param approved_userlist $userlist The approved context and user information to delete information for.
*/
public static function delete_data_for_users(approved_userlist $userlist) {
global $DB;
$context = $userlist->get_context();
$models = self::get_models_with_user_data();
$modelids = array_keys($models);
list($usersinsql, $baseparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED);
// Analytics prediction actions.
list($sql, $apaparams) = self::analytics_prediction_actions_context_sql($context->id, $modelids, $usersinsql);
$sql = "SELECT apa.id" . $sql;
$predictionactionids = $DB->get_fieldset_sql($sql, $baseparams + $apaparams);
if ($predictionactionids) {
list ($predictionactionidssql, $params) = $DB->get_in_or_equal($predictionactionids);
$DB->delete_records_select('analytics_prediction_actions', "id {$predictionactionidssql}", $params);
}
$baseparams['contextid'] = $context->id;
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
// Analytics predictions.
$joinusersql = $analyser->join_sample_user('ap');
$sql = "SELECT DISTINCT ap.id
FROM {analytics_predictions} ap
{$joinusersql}
WHERE ap.contextid = :contextid
AND ap.modelid = :modelid
AND u.id {$usersinsql}";
$params = $baseparams;
$params['modelid'] = $modelid;
$predictionids = $DB->get_fieldset_sql($sql, $params);
if ($predictionids) {
list($predictionidssql, $params) = $DB->get_in_or_equal($predictionids, SQL_PARAMS_NAMED);
$DB->delete_records_select('analytics_predictions', "id {$predictionidssql}", $params);
}
// Indicator calculations.
$joinusersql = $analyser->join_sample_user('aic');
$sql = "SELECT DISTINCT aic.id
FROM {analytics_indicator_calc} aic
{$joinusersql}
WHERE aic.contextid = :contextid
AND aic.sampleorigin = :analysersamplesorigin
AND u.id {$usersinsql}";
$params = $baseparams;
$params['analysersamplesorigin'] = $analyser->get_samples_origin();
$indicatorcalcids = $DB->get_fieldset_sql($sql, $params);
if ($indicatorcalcids) {
list ($indicatorcalcidssql, $params) = $DB->get_in_or_equal($indicatorcalcids, SQL_PARAMS_NAMED);
$DB->delete_records_select('analytics_indicator_calc', "id $indicatorcalcidssql", $params);
}
}
}
/**
* Returns a list of models with user data.
*
* @return \core_analytics\model[]
*/
private static function get_models_with_user_data() {
$models = \core_analytics\manager::get_all_models();
foreach ($models as $modelid => $model) {
$analyser = $model->get_analyser(['notimesplitting' => true]);
if (!$analyser->processes_user_data()) {
unset($models[$modelid]);
}
}
return $models;
}
/**
* Returns the sql query to query analytics_prediction_actions table by user ID.
*
* @param int $userid The user ID of the analytics prediction.
* @param int[] $modelids Model IDs to include in the SQL.
* @param string $contextsql Optional "in or equal" SQL to also query by context ID(s).
* @return array sql string in [0] and params in [1].
*/
private static function analytics_prediction_actions_user_sql($userid, $modelids, $contextsql = false) {
global $DB;
list($insql, $params) = $DB->get_in_or_equal($modelids, SQL_PARAMS_NAMED);
$sql = " FROM {analytics_predictions} ap
JOIN {context} ctx ON ctx.id = ap.contextid
JOIN {analytics_prediction_actions} apa ON apa.predictionid = ap.id
JOIN {analytics_models} am ON ap.modelid = am.id
WHERE apa.userid = :userid AND ap.modelid {$insql}";
$params['userid'] = $userid;
if ($contextsql) {
$sql .= " AND ap.contextid $contextsql";
}
return [$sql, $params];
}
/**
* Returns the sql query to query analytics_prediction_actions table by context ID.
*
* @param int $contextid The context ID of the analytics prediction.
* @param int[] $modelids Model IDs to include in the SQL.
* @param string $usersql Optional "in or equal" SQL to also query by user ID(s).
* @return array sql string in [0] and params in [1].
*/
private static function analytics_prediction_actions_context_sql($contextid, $modelids, $usersql = false) {
global $DB;
list($insql, $params) = $DB->get_in_or_equal($modelids, SQL_PARAMS_NAMED);
$sql = " FROM {analytics_predictions} ap
JOIN {analytics_prediction_actions} apa ON apa.predictionid = ap.id
WHERE ap.contextid = :contextid
AND ap.modelid {$insql}";
$params['contextid'] = $contextid;
if ($usersql) {
$sql .= " AND apa.userid {$usersql}";
}
return [$sql, $params];
}
}
+71
View File
@@ -0,0 +1,71 @@
<?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/>.
/**
* Regressors interface.
*
* @package core_analytics
* @copyright 2017 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();
/**
* Regressors interface.
*
* @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
*/
interface regressor extends predictor {
/**
* Train this processor regression model using the provided supervised learning dataset.
*
* @param string $uniqueid
* @param \stored_file $dataset
* @param string $outputdir
* @return \stdClass
*/
public function train_regression($uniqueid, \stored_file $dataset, $outputdir);
/**
* Estimates linear values for the provided dataset samples.
*
* @param string $uniqueid
* @param \stored_file $dataset
* @param mixed $outputdir
* @return void
*/
public function estimate($uniqueid, \stored_file $dataset, $outputdir);
/**
* Evaluates this processor regression model using the provided supervised learning dataset.
*
* @param string $uniqueid
* @param float $maxdeviation
* @param int $niterations
* @param \stored_file $dataset
* @param string $outputdir
* @param string $trainedmodeldir
* @return \stdClass
*/
public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
$outputdir, $trainedmodeldir);
}
@@ -0,0 +1,38 @@
<?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/>.
/**
* Model requirements exception.
*
* @package core_analytics
* @copyright 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;
/**
* Dummy class to identify model requirements exceptions.
*
* @package core_search
* @copyright David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class requirements_exception extends \moodle_exception {
}
+109
View File
@@ -0,0 +1,109 @@
<?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/>.
/**
* Moodle site analysable.
*
* @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();
/**
* Moodle site analysable.
*
* @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 site implements \core_analytics\analysable {
/**
* @var int
*/
protected $start;
/**
* @var int
*/
protected $end;
/**
* Analysable id
*
* @return int
*/
public function get_id() {
return SYSCONTEXTID;
}
/**
* Site.
*
* @return string
*/
public function get_name() {
return get_string('site');
}
/**
* Analysable context.
*
* @return \context
*/
public function get_context() {
return \context_system::instance();
}
/**
* Analysable start timestamp.
*
* @return int
*/
public function get_start() {
if (!empty($this->start)) {
return $this->start;
}
// Much faster than reading the first log in the site.
$admins = get_admins();
$this->start = 9999999999;
foreach ($admins as $admin) {
if ($admin->firstaccess < $this->start) {
$this->start = $admin->firstaccess;
}
}
return $this->start;
}
/**
* Analysable end timestamp.
*
* @return int
*/
public function get_end() {
if (!empty($this->end)) {
return $this->end;
}
$this->end = time();
return $this->end;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
// This file is part of Moodle - https://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/>.
/**
* Provides the {@link \core_analytics\stats} class.
*
* @package core_analytics
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Provides stats and meta information about the analytics usage on this site.
*
* @copyright 2019 David Mudrák <david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class stats {
/**
* Return the number of models enabled on this site.
*
* @return int
*/
public static function enabled_models(): int {
return count(manager::get_all_models(true));
}
/**
* Return the number of predictions generated by the system.
*
* @return int
*/
public static function predictions(): int {
global $DB;
return $DB->count_records('analytics_predictions');
}
/**
* Return the number of suggested actions executed by users.
*
* @return int
*/
public static function actions(): int {
global $DB;
return $DB->count_records('analytics_prediction_actions');
}
/**
* Return the number of suggested actions flagged as not useful.
*
* @return int
*/
public static function actions_not_useful(): int {
global $DB;
// Simple version using core's TYPE_NEGATIVE actions.
return $DB->count_records_select('analytics_prediction_actions',
'actionname = :notuseful OR actionname = :incorrectlyflagged',
['notuseful' => prediction::ACTION_NOT_USEFUL, 'incorrectlyflagged' => prediction::ACTION_INCORRECTLY_FLAGGED]);
}
}
+210
View File
@@ -0,0 +1,210 @@
<?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/>.
/**
* Moodle user analysable
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_analytics;
defined('MOODLE_INTERNAL') || die();
/**
* Moodle user analysable
*
* @package core_analytics
* @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class user implements \core_analytics\analysable {
/**
* @var bool Has this user data been already loaded.
*/
protected $loaded = false;
/**
* @var int $cachedid self::$cachedinstance analysable id.
*/
protected static $cachedid = 0;
/**
* @var \core_analytics\user $cachedinstance
*/
protected static $cachedinstance = null;
/**
* User object
*
* @var \stdClass
*/
protected $user = null;
/**
* The user context.
*
* @var \context_user
*/
protected $usercontext = null;
/** @var int Store current Unix timestamp. */
protected int $now = 0;
/**
* Constructor.
*
* Use self::instance() instead to get cached copies of the class. Instances obtained
* through this constructor will not be cached.
*
* @param int|\stdClass $user User id
* @param \context|null $context
* @return void
*/
public function __construct($user, ?\context $context = null) {
if (is_scalar($user)) {
$this->user = new \stdClass();
$this->user->id = $user;
} else {
$this->user = $user;
}
if (!is_null($context)) {
$this->usercontext = $context;
}
}
/**
* Returns an analytics user instance.
*
* Lazy load of analysable data.
*
* @param int|\stdClass $user User object or user id
* @param \context|null $context
* @return \core_analytics\user
*/
public static function instance($user, ?\context $context = null) {
$userid = $user;
if (!is_scalar($userid)) {
$userid = $user->id;
}
if (self::$cachedid === $userid) {
return self::$cachedinstance;
}
$cachedinstance = new \core_analytics\user($user, $context);
self::$cachedinstance = $cachedinstance;
self::$cachedid = (int)$userid;
return self::$cachedinstance;
}
/**
* get_id
*
* @return int
*/
public function get_id() {
return $this->user->id;
}
/**
* Loads the analytics user object.
*
* @return void
*/
protected function load() {
// The instance constructor could be already loaded with the full user object. Using email
// because it is a required user field.
if (empty($this->user->email)) {
$this->user = \core_user::get_user($this->user->id);
}
$this->usercontext = $this->get_context();
$this->now = time();
// Flag the instance as loaded.
$this->loaded = true;
}
/**
* The user full name.
*
* @return string
*/
public function get_name() {
if (!$this->loaded) {
$this->load();
}
return fullname($this->user);
}
/**
* get_context
*
* @return \context
*/
public function get_context() {
if ($this->usercontext === null) {
$this->usercontext = \context_user::instance($this->user->id);
}
return $this->usercontext;
}
/**
* Get the start timestamp.
*
* @return int
*/
public function get_start() {
if (!$this->loaded) {
$this->load();
}
return $this->user->timecreated;
}
/**
* Get the end timestamp.
*
* @return int
*/
public function get_end() {
return self::MAX_TIME;
}
/**
* Returns a user plain object.
*
* @return \stdClass
*/
public function get_user_data() {
if (!$this->loaded) {
$this->load();
}
return $this->user;
}
}