diff --git a/analytics/classes/calculable.php b/analytics/classes/calculable.php index 935b263c1cd..e23d6a649e1 100644 --- a/analytics/classes/calculable.php +++ b/analytics/classes/calculable.php @@ -35,6 +35,11 @@ defined('MOODLE_INTERNAL') || die(); */ abstract class calculable { + /** + * @var array[] + */ + protected $sampledata = array(); + /** * Returns a visible name for the indicator. * @@ -48,6 +53,41 @@ abstract class calculable { return get_called_class(); } + /** + * 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(); + } + + /** + * Retrieve the specified element associated to $sampleid. + * + * @param string $elementname + * @param int $sampleid + * @return \stdClass + */ + 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]; + } + /** * Returns the number of weeks a time range contains. * @@ -153,4 +193,32 @@ abstract class calculable { 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 + * @param array + * @return array + */ + private function array_merge_recursive_keep_keys() { + $arrays = func_get_args(); + $base = array_shift($arrays); + + foreach ($arrays as $array) { + reset($base); + while (list($key, $value) = each($array)) { + 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; + } } diff --git a/analytics/classes/local/analyser/base.php b/analytics/classes/local/analyser/base.php index 168a36ca044..7d832033c84 100644 --- a/analytics/classes/local/analyser/base.php +++ b/analytics/classes/local/analyser/base.php @@ -64,12 +64,31 @@ abstract class base { * This function returns the list of samples that can be calculated. * * @param \core_analytics\analysable $analysable - * @return array array[0] = int[], array[1] = array + * @return array array[0] = int[] (sampleids) and array[1] = array (samplesdata) */ abstract protected function get_all_samples(\core_analytics\analysable $analysable); + /** + * get_samples + * + * @param int[] $sampleids + * @return array array[0] = int[] (sampleids) and array[1] = array (samplesdata) + */ abstract public function get_samples($sampleids); + /** + * get_sample_analysable + * + * @param int $sampleid + * @return \core_analytics\analysable + */ + abstract public function get_sample_analysable($sampleid); + + /** + * get_samples_origin + * + * @return string + */ abstract protected function get_samples_origin(); /** @@ -80,6 +99,14 @@ abstract class base { */ abstract public function sample_access_context($sampleid); + /** + * sample_description + * + * @param int $sampleid + * @param int $contextid + * @param array $sampledata + * @return array array(string, \renderable) + */ abstract public function sample_description($sampleid, $contextid, $sampledata); protected function provided_sample_data() { @@ -174,7 +201,7 @@ abstract class base { $a = new \stdClass(); $a->analysableid = $analysable->get_id(); $a->result = $result; - $this->log[] = get_string('analysablenotvalidfortarget', 'analytics', $a); + $this->add_log(get_string('analysablenotvalidfortarget', 'analytics', $a)); return array(); } @@ -221,12 +248,27 @@ abstract class base { $a = new \stdClass(); $a->analysableid = $analysable->get_id(); $a->errors = implode(', ', $errors); - $this->log[] = get_string('analysablenotused', 'analytics', $a); + $this->add_log(get_string('analysablenotused', 'analytics', $a)); } return $files; } + /** + * add_log + * + * @param string $string + * @return void + */ + public function add_log($string) { + $this->log[] = $string; + } + + /** + * get_logs + * + * @return string[] + */ public function get_logs() { return $this->log; } @@ -307,7 +349,11 @@ abstract class base { foreach ($this->indicators as $key => $indicator) { // The analyser attaches the main entities the sample depends on and are provided to the // indicator to calculate the sample. - $this->indicators[$key]->set_sample_data($samplesdata); + $this->indicators[$key]->add_sample_data($samplesdata); + } + if ($target) { + // Also provided to the target. + $target->add_sample_data($samplesdata); } // Here we start the memory intensive process that will last until $data var is diff --git a/analytics/classes/local/analyser/courses.php b/analytics/classes/local/analyser/courses.php index 9cb04d6d696..65efca4f854 100644 --- a/analytics/classes/local/analyser/courses.php +++ b/analytics/classes/local/analyser/courses.php @@ -37,6 +37,16 @@ class courses extends sitewide { return 'course'; } + /** + * get_sample_analysable + * + * @param int $sampleid + * @return \core_analytics\analysable + */ + public function get_sample_analysable($sampleid) { + return new \core_analytics\site(); + } + protected function provided_sample_data() { return array('course', 'context'); } @@ -81,8 +91,17 @@ class courses extends sitewide { return array($sampleids, $courses); } + /** + * sample_description + * + * @param int $sampleid + * @param int $contextid + * @param array $sampledata + * @return void + */ public function sample_description($sampleid, $contextid, $sampledata) { - $description = format_string($sampledata['course'], true, array('context' => $contextid)); - return array($description, false); + $description = format_string($sampledata['course']->fullname, true, array('context' => $sampledata['context'])); + $courseimage = new \pix_icon('i/course', get_string('course')); + return array($description, $courseimage); } } diff --git a/analytics/classes/local/analyser/student_enrolments.php b/analytics/classes/local/analyser/student_enrolments.php index 322da74fe2b..8e135e6e9ea 100644 --- a/analytics/classes/local/analyser/student_enrolments.php +++ b/analytics/classes/local/analyser/student_enrolments.php @@ -48,6 +48,11 @@ class student_enrolments extends by_course { return \context_course::instance($this->get_sample_course($sampleid)); } + public function get_sample_analysable($sampleid) { + $course = enrol_get_course_by_user_enrolment_id($ueid); + return \core_analytics\course($course); + } + protected function provided_sample_data() { return array('user_enrolments', 'context', 'course', 'user'); } diff --git a/analytics/classes/local/indicator/any_access_after_end.php b/analytics/classes/local/indicator/any_access_after_end.php index c1cc78cf2e2..72745cb5c54 100644 --- a/analytics/classes/local/indicator/any_access_after_end.php +++ b/analytics/classes/local/indicator/any_access_after_end.php @@ -43,7 +43,7 @@ class any_access_after_end extends binary { return array('user', 'course', 'context'); } - public function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) { + protected function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) { global $DB; $user = $this->retrieve('user', $sampleid); diff --git a/analytics/classes/local/indicator/any_access_before_start.php b/analytics/classes/local/indicator/any_access_before_start.php index 5ab2ad401f8..67be6dffc41 100644 --- a/analytics/classes/local/indicator/any_access_before_start.php +++ b/analytics/classes/local/indicator/any_access_before_start.php @@ -43,7 +43,7 @@ class any_access_before_start extends binary { return array('user', 'course', 'context'); } - public function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) { + protected function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) { global $DB; $user = $this->retrieve('user', $sampleid); diff --git a/analytics/classes/local/indicator/any_write_action.php b/analytics/classes/local/indicator/any_write_action.php index 2c1b173f0db..c529a9af72c 100644 --- a/analytics/classes/local/indicator/any_write_action.php +++ b/analytics/classes/local/indicator/any_write_action.php @@ -44,7 +44,7 @@ class any_write_action extends binary { return array('context'); } - public function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { + protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { global $DB; $select = ''; diff --git a/analytics/classes/local/indicator/base.php b/analytics/classes/local/indicator/base.php index 726fa178ebb..f08c310d4b3 100644 --- a/analytics/classes/local/indicator/base.php +++ b/analytics/classes/local/indicator/base.php @@ -39,11 +39,6 @@ abstract class base extends \core_analytics\calculable { const MAX_VALUE = 1; - /** - * @var array[] - */ - protected $sampledata = array(); - /** * Converts the calculated indicators to feature/s. * @@ -124,19 +119,6 @@ abstract class base extends \core_analytics\calculable { return $calculations; } - public function set_sample_data($data) { - $this->sampledata = $data; - } - - protected function retrieve($tablename, $sampleid) { - if (empty($this->sampledata[$sampleid]) || empty($this->sampledata[$sampleid][$tablename])) { - // 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][$tablename]; - } - protected static function add_samples_averages() { return false; } diff --git a/analytics/classes/local/indicator/binary.php b/analytics/classes/local/indicator/binary.php index 5827484739f..410adf4fff6 100644 --- a/analytics/classes/local/indicator/binary.php +++ b/analytics/classes/local/indicator/binary.php @@ -36,7 +36,9 @@ defined('MOODLE_INTERNAL') || die(); abstract class binary extends discrete { public static final function get_classes() { - return array(0, 1); + // It does not really matter, all \core_analytics\local\indicator\discrete get_classes calls have been overwriten as we + // only need 1 column here. + return array(0); } public function get_display_value($value, $subtype = false) { diff --git a/analytics/classes/local/indicator/community_of_inquiry_activity.php b/analytics/classes/local/indicator/community_of_inquiry_activity.php index bcaa54381a7..952ed8933e2 100644 --- a/analytics/classes/local/indicator/community_of_inquiry_activity.php +++ b/analytics/classes/local/indicator/community_of_inquiry_activity.php @@ -514,7 +514,7 @@ abstract class community_of_inquiry_activity extends linear { * @return float|int|null * @throws \coding_exception */ - public function calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) { + protected function calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) { if ($this->get_indicator_type() == self::INDICATOR_COGNITIVE) { return $this->cognitive_calculate_sample($sampleid, $tablename, $starttime, $endtime); } else if ($this->get_indicator_type() == self::INDICATOR_SOCIAL) { diff --git a/analytics/classes/local/indicator/read_actions.php b/analytics/classes/local/indicator/read_actions.php index 36f4553d21a..6728596021e 100644 --- a/analytics/classes/local/indicator/read_actions.php +++ b/analytics/classes/local/indicator/read_actions.php @@ -44,7 +44,7 @@ class read_actions extends linear { return array('context'); } - public function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { + protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { global $DB; $select = ''; diff --git a/analytics/classes/local/indicator/user_profile_set.php b/analytics/classes/local/indicator/user_profile_set.php index e8af023d58c..b55c92e7d3b 100644 --- a/analytics/classes/local/indicator/user_profile_set.php +++ b/analytics/classes/local/indicator/user_profile_set.php @@ -44,7 +44,7 @@ class user_profile_set extends linear { return array('user'); } - public function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { + protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) { global $CFG; $user = $this->retrieve('user', $sampleid); diff --git a/analytics/classes/local/indicator/user_track_forums.php b/analytics/classes/local/indicator/user_track_forums.php index 60569f67408..e77852e9169 100644 --- a/analytics/classes/local/indicator/user_track_forums.php +++ b/analytics/classes/local/indicator/user_track_forums.php @@ -43,7 +43,7 @@ class user_track_forums extends binary { return array('user'); } - public function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) { + protected function calculate_sample($sampleid, $samplesorigin, $starttime = false, $endtime = false) { $user = $this->retrieve('user', $sampleid); diff --git a/analytics/classes/local/target/base.php b/analytics/classes/local/target/base.php index 2df019b2af5..f99aef22316 100644 --- a/analytics/classes/local/target/base.php +++ b/analytics/classes/local/target/base.php @@ -61,6 +61,15 @@ abstract class base extends \core_analytics\calculable { */ abstract public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true); + /** + * is_valid_sample + * + * @param int $sampleid + * @param \core_analytics\analysable $analysable + * @return void + */ + abstract public function is_valid_sample($sampleid, \core_analytics\analysable $analysable); + /** * Calculates this target for the provided samples. * @@ -68,9 +77,20 @@ abstract class base extends \core_analytics\calculable { * * @param int $sample * @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); + abstract protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false); + + /** + * Based on facts (processed by machine learning backends) by default. + * + * @return bool + */ + public static function based_on_assumptions() { + return false; + } public function prediction_actions(\core_analytics\prediction $prediction) { global $PAGE; @@ -216,7 +236,7 @@ abstract class base extends \core_analytics\calculable { * @param integer $endtime endtime is not necessary when calculating targets * @return array The format to follow is [userid] = scalar|null */ - public function calculate($sampleids, \core_analytics\analysable $analysable) { + public function calculate(&$sampleids, \core_analytics\analysable $analysable) { if (!PHPUNIT_TEST && CLI_SCRIPT) { echo '.'; @@ -224,7 +244,15 @@ abstract class base extends \core_analytics\calculable { $calculations = []; foreach ($sampleids as $sampleid => $unusedsampleid) { - $calculatedvalue = $this->calculate_sample($sampleid, $analysable); + + if (!$this->is_valid_sample($sampleid, $analysable)) { + // Skip it and remove the sample from the list of calculated samples. + unset($sampleids[$sampleid]); + continue; + } + + // No time limits when calculating the target to train models. + $calculatedvalue = $this->calculate_sample($sampleid, $analysable, false, false); if (!is_null($calculatedvalue)) { if ($this->is_linear() && ($calculatedvalue > static::get_max_value() || $calculatedvalue < static::get_min_value())) { diff --git a/analytics/classes/local/target/discrete.php b/analytics/classes/local/target/discrete.php index 49df96cdfcc..b6033b85db6 100644 --- a/analytics/classes/local/target/discrete.php +++ b/analytics/classes/local/target/discrete.php @@ -106,7 +106,7 @@ abstract class discrete extends base { * a target with classes 'grade 0-3', 'grade 3-6', 'grade 6-8' and 'grade 8-10' is interested in flagging both 'grade 0-3' * and 'grade 3-6'. On the other hand, a target like dropout risk with classes 'yes', 'no' may just be interested in 'yes'. * - * @return array + * @return array List of values that will be ignored (array keys are ignored). */ protected function ignored_predicted_classes() { // Coding exception as this will only be called if this target have non-linear values. diff --git a/analytics/classes/local/time_splitting/base.php b/analytics/classes/local/time_splitting/base.php index b1b795724c6..5e721e33644 100644 --- a/analytics/classes/local/time_splitting/base.php +++ b/analytics/classes/local/time_splitting/base.php @@ -100,7 +100,7 @@ abstract class base { } public function ready_to_predict($range) { - if ($range['end'] <= time()) { + if ($range['time'] <= time()) { return true; } return false; @@ -109,13 +109,19 @@ abstract class base { /** * Calculates the course students indicators and targets. * - * @return void + * @param array $sampleids + * @param string $samplesorigin + * @param \core_analytics\local\indicator\base $indicators + * @param array $ranges + * @param \core_analytics\local\target\base $target + * @return array */ - public function calculate($sampleids, $samplesorigin, $indicators, $ranges, $target = false) { + public function calculate(&$sampleids, $samplesorigin, $indicators, $ranges, $target = false) { $calculatedtarget = false; if ($target) { - // We first calculate the target because analysable data may still be invalid, we need to stop if it is not. + // We first calculate the target because analysable data may still be invalid or none + // of the analysable samples may be valid ($sampleids is also passed by reference). $calculatedtarget = $target->calculate($sampleids, $this->analysable); // We remove samples we can not calculate their target. @@ -275,6 +281,18 @@ abstract class base { return $this->ranges; } + /** + * get_range_by_index + * + * @return array + */ + public function get_range_by_index($rangeindex) { + if (!isset($this->ranges[$rangeindex])) { + return false; + } + return $this->ranges[$rangeindex]; + } + public function append_rangeindex($sampleid, $rangeindex) { return $sampleid . '-' . $rangeindex; } @@ -322,7 +340,8 @@ abstract class base { */ protected function validate_ranges() { foreach ($this->ranges as $key => $range) { - if (!isset($this->ranges[$key]['start']) || !isset($this->ranges[$key]['end'])) { + 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.'); } diff --git a/analytics/classes/local/time_splitting/deciles.php b/analytics/classes/local/time_splitting/deciles.php index 5237622bd92..d969cc1442b 100644 --- a/analytics/classes/local/time_splitting/deciles.php +++ b/analytics/classes/local/time_splitting/deciles.php @@ -44,9 +44,12 @@ class deciles extends base { $ranges = array(); for ($i = 0; $i < 10; $i++) { + $start = $this->analysable->get_start() + ($rangeduration * $i); + $end = $this->analysable->get_start() + ($rangeduration * ($i + 1)); $ranges[] = array( - 'start' => $this->analysable->get_start() + ($rangeduration * $i), - 'end' => $this->analysable->get_start() + ($rangeduration * ($i + 1)) + 'start' => $start, + 'end' => $end, + 'time' => $end ); } diff --git a/analytics/classes/local/time_splitting/deciles_accum.php b/analytics/classes/local/time_splitting/deciles_accum.php index cea73f9798c..8eecb1145bf 100644 --- a/analytics/classes/local/time_splitting/deciles_accum.php +++ b/analytics/classes/local/time_splitting/deciles_accum.php @@ -44,9 +44,11 @@ class deciles_accum extends base { $ranges = array(); for ($i = 0; $i < 10; $i++) { + $end = $this->analysable->get_start() + ($rangeduration * ($i + 1)); $ranges[] = array( 'start' => $this->analysable->get_start(), - 'end' => $this->analysable->get_start() + ($rangeduration * ($i + 1)) + 'end' => $end, + 'time' => $end ); } diff --git a/analytics/classes/local/time_splitting/no_splitting.php b/analytics/classes/local/time_splitting/no_splitting.php index d09aab0254b..54a784ec6c9 100644 --- a/analytics/classes/local/time_splitting/no_splitting.php +++ b/analytics/classes/local/time_splitting/no_splitting.php @@ -42,7 +42,9 @@ class no_splitting extends base { return [ [ 'start' => 0, - 'end' => \core_analytics\analysable::MAX_TIME + 'end' => \core_analytics\analysable::MAX_TIME, + // Time is ignored as we overwrite ready_to_predict. + 'time' => 0 ] ]; } diff --git a/analytics/classes/local/time_splitting/quarters.php b/analytics/classes/local/time_splitting/quarters.php index 4f02fb6c823..c9e55384111 100644 --- a/analytics/classes/local/time_splitting/quarters.php +++ b/analytics/classes/local/time_splitting/quarters.php @@ -44,16 +44,20 @@ class quarters extends base { return [ [ 'start' => $this->analysable->get_start(), - 'end' => $this->analysable->get_start() + $duration + 'end' => $this->analysable->get_start() + $duration, + 'time' => $this->analysable->get_start() + $duration ], [ 'start' => $this->analysable->get_start() + $duration, - 'end' => $this->analysable->get_start() + ($duration * 2) + 'end' => $this->analysable->get_start() + ($duration * 2), + 'time' => $this->analysable->get_start() + ($duration * 2) ], [ 'start' => $this->analysable->get_start() + ($duration * 2), - 'end' => $this->analysable->get_start() + ($duration * 3) + 'end' => $this->analysable->get_start() + ($duration * 3), + 'time' => $this->analysable->get_start() + ($duration * 3) ], [ 'start' => $this->analysable->get_start() + ($duration * 3), - 'end' => $this->analysable->get_start() + ($duration * 4) + 'end' => $this->analysable->get_start() + ($duration * 4), + 'time' => $this->analysable->get_start() + ($duration * 4) ] ]; } diff --git a/analytics/classes/local/time_splitting/quarters_accum.php b/analytics/classes/local/time_splitting/quarters_accum.php index 067fd44fdbb..e455341225b 100644 --- a/analytics/classes/local/time_splitting/quarters_accum.php +++ b/analytics/classes/local/time_splitting/quarters_accum.php @@ -44,16 +44,20 @@ class quarters_accum extends base { return [ [ 'start' => $this->analysable->get_start(), - 'end' => $this->analysable->get_start() + $duration + 'end' => $this->analysable->get_start() + $duration, + 'time' => $this->analysable->get_start() + $duration ], [ 'start' => $this->analysable->get_start(), - 'end' => $this->analysable->get_start() + ($duration * 2) + 'end' => $this->analysable->get_start() + ($duration * 2), + 'time' => $this->analysable->get_start() + ($duration * 2) ], [ 'start' => $this->analysable->get_start(), - 'end' => $this->analysable->get_start() + ($duration * 3) + 'end' => $this->analysable->get_start() + ($duration * 3), + 'time' => $this->analysable->get_start() + ($duration * 3) ], [ 'start' => $this->analysable->get_start(), - 'end' => $this->analysable->get_start() + ($duration * 4) + 'end' => $this->analysable->get_start() + ($duration * 4), + 'time' => $this->analysable->get_start() + ($duration * 4) ] ]; } diff --git a/analytics/classes/local/time_splitting/single_range.php b/analytics/classes/local/time_splitting/single_range.php index b8e7ba5e1ae..61d2a294e0a 100644 --- a/analytics/classes/local/time_splitting/single_range.php +++ b/analytics/classes/local/time_splitting/single_range.php @@ -36,7 +36,8 @@ class single_range extends base { return [ [ 'start' => $this->analysable->get_start(), - 'end' => $this->analysable->get_end() + 'end' => $this->analysable->get_end(), + 'time' => $this->analysable->get_end() ] ]; } diff --git a/analytics/classes/local/time_splitting/week_before_course_start.php b/analytics/classes/local/time_splitting/week_before_course_start.php new file mode 100644 index 00000000000..5b2b462d09c --- /dev/null +++ b/analytics/classes/local/time_splitting/week_before_course_start.php @@ -0,0 +1,57 @@ +. + +/** + * One week before the course start. + * + * @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(); + +class week_before_course_start extends base { + + public function get_name() { + return get_string('timesplitting:weekbeforecoursestart', 'analytics'); + } + + public function is_valid_analysable(\core_analytics\analysable $analysable) { + if ($analysable instanceof \core_analytics\site) { + // Defer checking to is_valid_sample. + return true; + } + if ($analysable instanceof \core_analytics\course && !$analysable->get_start()) { + return get_string('nocoursestart', 'analytics'); + } + + // Default to true. + return true; + } + + protected function define_ranges() { + return [ + [ + 'start' => 0, + 'end' => \core_analytics\analysable::MAX_TIME, + 'time' => $this->analysable->get_start() - WEEKSECS + ] + ]; + } +} diff --git a/analytics/classes/local/time_splitting/weekly.php b/analytics/classes/local/time_splitting/weekly.php index 50785b8a008..72d2dd81754 100644 --- a/analytics/classes/local/time_splitting/weekly.php +++ b/analytics/classes/local/time_splitting/weekly.php @@ -37,7 +37,7 @@ abstract class weekly extends base { $nweeks = round($diff / WEEKSECS); if ($nweeks > 520) { // More than 10 years... - return false; + return get_string('coursetoolong', 'analytics'); } return parent::is_valid_analysable($analysable); } @@ -60,7 +60,8 @@ abstract class weekly extends base { $ranges[] = array( 'start' => $rangestart, - 'end' => $rangeend + 'end' => $rangeend, + 'time' => $rangeend ); $i++; diff --git a/analytics/classes/local/time_splitting/weekly_accum.php b/analytics/classes/local/time_splitting/weekly_accum.php index 22d793c5779..99a0c49b852 100644 --- a/analytics/classes/local/time_splitting/weekly_accum.php +++ b/analytics/classes/local/time_splitting/weekly_accum.php @@ -62,7 +62,8 @@ abstract class weekly_accum extends base { // Accumulative, always from the course start. $ranges[] = array( 'start' => $this->analysable->get_start(), - 'end' => $rangeend + 'end' => $rangeend, + 'time' => $rangeend ); $i++; diff --git a/analytics/classes/model.php b/analytics/classes/model.php index 7fe00e9be2c..33d43aad61c 100644 --- a/analytics/classes/model.php +++ b/analytics/classes/model.php @@ -160,7 +160,7 @@ class model { * * It includes the indicators that are part of the model. * - * @return \core_analytics\local\indicator\base + * @return \core_analytics\local\indicator\base[] */ public function get_potential_indicators() { @@ -253,13 +253,14 @@ class model { } /** - * create + * Creates a new model. Enables it if $timesplittingid is specified. * * @param \core_analytics\local\target\base $target * @param \core_analytics\local\indicator\base[] $indicators + * @param string $timesplittingid The time splitting method id (its fully qualified class name) * @return \core_analytics\model */ - public static function create(\core_analytics\local\target\base $target, array $indicators) { + public static function create(\core_analytics\local\target\base $target, array $indicators, $timesplittingid = false) { global $USER, $DB; $indicatorclasses = self::indicator_classes($indicators); @@ -279,10 +280,28 @@ class model { // Get db defaults. $modelobj = $DB->get_record('analytics_models', array('id' => $id), '*', MUST_EXIST); - return new static($modelobj); + $model = new static($modelobj); + + if ($timesplittingid) { + $model->enable($timesplittingid); + } + + if ($model->is_static()) { + $model->mark_as_trained(); + } + + return $model; } - public function update($enabled, $indicators, $timesplitting = '') { + /** + * update + * + * @param int|bool $enabled + * @param \core_analytics\local\indicator\base[] $indicators + * @param string $timesplittingid + * @return void + */ + public function update($enabled, $indicators, $timesplittingid = '') { global $USER, $DB; $now = time(); @@ -290,7 +309,7 @@ class model { $indicatorclasses = self::indicator_classes($indicators); $indicatorsstr = json_encode($indicatorclasses); - if ($this->model->timesplitting !== $timesplitting || + if ($this->model->timesplitting !== $timesplittingid || $this->model->indicators !== $indicatorsstr) { // We update the version of the model so different time splittings are not mixed up. $this->model->version = $now; @@ -304,9 +323,9 @@ class model { // Reset trained flag. $this->model->trained = 0; } - $this->model->enabled = $enabled; + $this->model->enabled = intval($enabled); $this->model->indicators = $indicatorsstr; - $this->model->timesplitting = $timesplitting; + $this->model->timesplitting = $timesplittingid; $this->model->timemodified = $now; $this->model->usermodified = $USER->id; @@ -337,6 +356,13 @@ class model { */ public function evaluate($options = array()) { + if ($this->is_static()) { + $this->get_analyser()->add_log(get_string('noevaluationbasedassumptions', 'analytics')); + $result = new \stdClass(); + $result->status = self::OK; + return $result; + } + // Increase memory limit. $this->increase_memory(); @@ -409,10 +435,17 @@ class model { public function train() { global $DB; + if ($this->is_static()) { + $this->get_analyser()->add_log(get_string('notrainingbasedassumptions', 'analytics')); + $result = new \stdClass(); + $result->status = self::OK; + return $result; + } + // Increase memory limit. $this->increase_memory(); - if ($this->model->enabled == false || empty($this->model->timesplitting)) { + if (!$this->is_enabled() || empty($this->model->timesplitting)) { throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id); } @@ -466,7 +499,7 @@ class model { // Increase memory limit. $this->increase_memory(); - if ($this->model->enabled == false || empty($this->model->timesplitting)) { + if (!$this->is_enabled() || empty($this->model->timesplitting)) { throw new \moodle_exception('invalidtimesplitting', 'analytics', '', $this->model->id); } @@ -478,7 +511,9 @@ class model { $outputdir = $this->get_output_dir(array('execution')); // Before get_unlabelled_data call so we get an early exception if it is not ready. - $predictor = \core_analytics\manager::get_predictions_processor(); + if (!$this->is_static()) { + $predictor = \core_analytics\manager::get_predictions_processor(); + } $samplesdata = $this->get_analyser()->get_unlabelled_data(); @@ -498,56 +533,73 @@ class model { throw new \moodle_exception('erroralreadypredict', 'analytics', '', $samplesfile->get_id()); } - $predictorresult = $predictor->predict($this->get_unique_id(), $samplesfile, $outputdir); + $indicatorcalculations = \core_analytics\dataset_manager::get_structured_data($samplesfile); + // Prepare the results object. $result = new \stdClass(); - $result->status = $predictorresult->status; - $result->info = $predictorresult->info; - $calculations = \core_analytics\dataset_manager::get_structured_data($samplesfile); + if ($this->is_static()) { + // Prediction based on assumptions. + $result->status = \core_analytics\model::OK; + $result->info = []; + $result->predictions = $this->get_static_predictions($indicatorcalculations); + + } else { + // Defer the prediction to the machine learning backend. + $predictorresult = $predictor->predict($this->get_unique_id(), $samplesfile, $outputdir); + + $result->status = $predictorresult->status; + $result->info = $predictorresult->info; + $result->predictions = array(); + if ($predictorresult->predictions) { + foreach ($predictorresult->predictions as $sampleinfo) { + + // We parse each prediction + switch (count($sampleinfo)) { + case 1: + // For whatever reason the predictions processor could not process this sample, we + // skip it and do nothing with it. + debugging($this->model->id . ' model predictions processor could not process the sample with id ' . + $sampleinfo[0], DEBUG_DEVELOPER); + continue; + case 2: + // Prediction processors that do not return a prediction score will have the maximum prediction + // score. + list($uniquesampleid, $prediction) = $sampleinfo; + $predictionscore = 1; + break; + case 3: + list($uniquesampleid, $prediction, $predictionscore) = $sampleinfo; + break; + default: + break; + } + $predictiondata = (object)['prediction' => $prediction, 'predictionscore' => $predictionscore]; + $result->predictions[$uniquesampleid] = $predictiondata; + } + } + } // Here we will store all predictions' contexts, this will be used to limit which users will see those predictions. $samplecontexts = array(); - if ($predictorresult) { - $result->predictions = $predictorresult->predictions; - foreach ($result->predictions as $sampleinfo) { + if ($result->predictions) { + foreach ($result->predictions as $uniquesampleid => $prediction) { - // We parse each prediction - switch (count($sampleinfo)) { - case 1: - // For whatever reason the predictions processor could not process this sample, we - // skip it and do nothing with it. - debugging($this->model->id . ' model predictions processor could not process the sample with id ' . - $sampleinfo[0], DEBUG_DEVELOPER); - continue; - case 2: - // Prediction processors that do not return a prediction score will have the maximum prediction - // score. - list($uniquesampleid, $prediction) = $sampleinfo; - $predictionscore = 1; - break; - case 3: - list($uniquesampleid, $prediction, $predictionscore) = $sampleinfo; - break; - default: - break; - } - - if ($this->get_target()->triggers_callback($prediction, $predictionscore)) { + if ($this->get_target()->triggers_callback($prediction->prediction, $prediction->predictionscore)) { // The unique sample id contains both the sampleid and the rangeindex. list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid); // Store the predicted values. - $samplecontext = $this->save_prediction($sampleid, $rangeindex, $prediction, $predictionscore, - json_encode($calculations[$uniquesampleid])); + $samplecontext = $this->save_prediction($sampleid, $rangeindex, $prediction->prediction, $prediction->predictionscore, + json_encode($indicatorcalculations[$uniquesampleid])); // Also store all samples context to later generate insights or whatever action the target wants to perform. $samplecontexts[$samplecontext->id] = $samplecontext; $this->get_target()->prediction_callback($this->model->id, $sampleid, $rangeindex, $samplecontext, - $prediction, $predictionscore); + $prediction->prediction, $prediction->predictionscore); } } } @@ -568,6 +620,92 @@ class model { return $result; } + /** + * get_static_predictions + * + * @param array $indicatorcalculations + * @return \stdClass[] + */ + protected function get_static_predictions(&$indicatorcalculations) { + + // Group samples by analysable for \core_analytics\local\target::calculate. + $analysables = array(); + // List all sampleids together. + $sampleids = array(); + + foreach ($indicatorcalculations as $uniquesampleid => $indicators) { + list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid); + + $analysable = $this->get_analyser()->get_sample_analysable($sampleid); + $analysableclass = get_class($analysable); + if (empty($analysables[$analysableclass])) { + $analysables[$analysableclass] = array(); + } + if (empty($analysables[$analysableclass][$rangeindex])) { + $analysables[$analysableclass][$rangeindex] = (object)[ + 'analysable' => $analysable, + 'indicatorsdata' => array(), + 'sampleids' => array() + ]; + } + // Using the sampleid as a key so we can easily merge indicators data later. + $analysables[$analysableclass][$rangeindex]->indicatorsdata[$sampleid] = $indicators; + // We could use indicatorsdata keys but the amount of redundant data is not that big and leaves code below cleaner. + $analysables[$analysableclass][$rangeindex]->sampleids[$sampleid] = $sampleid; + + // Accumulate sample ids to get all their associated data in 1 single db query (analyser::get_samples). + $sampleids[$sampleid] = $sampleid; + } + + // Get all samples data. + list($sampleids, $samplesdata) = $this->get_analyser()->get_samples($sampleids); + + // Calculate the targets. + $calculations = array(); + foreach ($analysables as $analysableclass => $rangedata) { + foreach ($rangedata as $rangeindex => $data) { + + // Attach samples data and calculated indicators data. + $this->get_target()->clear_sample_data(); + $this->get_target()->add_sample_data($samplesdata); + $this->get_target()->add_sample_data($data->indicatorsdata); + + // Append new elements (we can not get duplicated because sample-analysable relation is N-1). + $range = $this->get_time_splitting()->get_range_by_index($rangeindex); + $calculations = $this->get_target()->calculate($data->sampleids, $data->analysable, $range['start'], $range['end']); + + // Missing $indicatorcalculations values in $calculations are caused by is_valid_sample. We need to remove + // these $uniquesampleid from $indicatorcalculations because otherwise they will be stored as calculated + // by self::save_prediction. + $indicatorcalculations = array_filter($indicatorcalculations, function($indicators, $uniquesampleid) use ($calculations) { + list($sampleid, $rangeindex) = $this->get_time_splitting()->infer_sample_info($uniquesampleid); + if (!isset($calculations[$sampleid])) { + debugging($uniquesampleid . ' discarded by is_valid_sample'); + return false; + } + return true; + }, ARRAY_FILTER_USE_BOTH); + + foreach ($calculations as $sampleid => $value) { + + $uniquesampleid = $this->get_time_splitting()->append_rangeindex($sampleid, $rangeindex); + + // Null means that the target couldn't calculate the sample, we also remove them from $indicatorcalculations. + if (is_null($calculations[$sampleid])) { + debugging($uniquesampleid . ' discarded by is_valid_sample'); + unset($indicatorcalculations[$uniquesampleid]); + continue; + } + + // Even if static predictions are based on assumptions we flag them as 100% because they are 100% + // true according to what the developer defined. + $predictions[$uniquesampleid] = (object)['prediction' => $value, 'predictionscore' => 1]; + } + } + } + return $predictions; + } + /** * save_prediction * @@ -631,6 +769,15 @@ class model { $this->uniqueid = null; } + /** + * is_static + * + * @return bool + */ + public function is_static() { + return (bool)$this->get_target()->based_on_assumptions(); + } + /** * is_enabled * @@ -646,7 +793,8 @@ class model { * @return bool */ public function is_trained() { - return (bool)$this->model->trained; + // Models which targets are based on assumptions do not need training. + return (bool)$this->model->trained || $this->is_static(); } /** diff --git a/lang/en/analytics.php b/lang/en/analytics.php index 8d35fd92ff5..e4ecddaeb67 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -59,11 +59,12 @@ $string['messageprovider:insights'] = 'Insights generated by prediction models'; $string['modeloutputdir'] = 'Models output directory'; $string['modeloutputdirinfo'] = 'Directory where prediction processors store all evaluation info. Useful for debugging and research.'; $string['nocourses'] = 'No courses to analyse'; +$string['nocoursestart'] = 'No course start'; $string['nodata'] = 'No data available'; -$string['nodatatotrain'] = 'There is no data to use as training data'; $string['nonewdata'] = 'No new data available'; $string['nonewtimeranges'] = 'No new time ranges, nothing to predict'; $string['nopredictionsyet'] = 'No predictions available yet'; +$string['notrainingbasedassumptions'] = 'Models based on assumptions do not need training'; $string['novaliddata'] = 'No valid data available'; $string['predictionsprocessor'] = 'Predictions processor'; $string['predictionsprocessor_help'] = 'Prediction processors are the machine learning backends that process the datasets generated by calculating models\' indicators and targets.'; @@ -78,8 +79,10 @@ $string['timesplitting:nosplitting'] = 'No time splitting'; $string['timesplitting:quarters'] = 'Quarters'; $string['timesplitting:quartersaccum'] = 'Quarters accumulative'; $string['timesplitting:singlerange'] = 'Single range'; +$string['timesplitting:weekbeforecoursestart'] = 'One week before the course start'; $string['timesplitting:weekly'] = 'Weekly'; $string['timesplitting:weeklyaccum'] = 'Weekly accumulative'; $string['timesplittingmethod'] = 'Time splitting method'; $string['timesplittingmethod_help'] = 'The time splitting method divides the course duration in parts, the predictions engine will run at the end of these parts. It is recommended that you only enable the time splitting methods you could be interested on using; the evaluation process will iterate through all of them so the more time splitting methods to go through the slower the evaluation process will be.'; +$string['coursetoolong'] = 'The course is too long'; $string['viewprediction'] = 'View prediction details'; diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 845118ebcb2..6c9c660f1db 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1015,6 +1015,7 @@ $string['includeroleassignments'] = 'Include role assignments'; $string['includesitefiles'] = 'Include site files used in this course'; $string['includeuserfiles'] = 'Include user files'; $string['increasesections'] = 'Increase the number of sections'; +$string['indicator:noteacher'] = 'There are no teachers'; $string['info'] = 'Information'; $string['institution'] = 'Institution'; $string['instudentview'] = 'in student view';