77103e4aded9d1b1468bae2f61ecbc25617aa765
[moodle.git] / analytics / tests / prediction_test.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Unit tests for evaluation, training and prediction.
19  *
20  * @package   core_analytics
21  * @copyright 2017 David Monlla├│ {@link http://www.davidmonllao.com}
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 require_once(__DIR__ . '/fixtures/test_indicator_max.php');
28 require_once(__DIR__ . '/fixtures/test_indicator_min.php');
29 require_once(__DIR__ . '/fixtures/test_indicator_fullname.php');
30 require_once(__DIR__ . '/fixtures/test_indicator_random.php');
31 require_once(__DIR__ . '/fixtures/test_target_shortname.php');
33 /**
34  * Unit tests for evaluation, training and prediction.
35  *
36  * @package   core_analytics
37  * @copyright 2017 David Monlla├│ {@link http://www.davidmonllao.com}
38  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39  */
40 class core_analytics_prediction_testcase extends advanced_testcase {
42     /**
43      * @dataProvider provider_ml_training_and_prediction
44      * @param string $timesplittingid
45      * @param int $npredictedranges
46      * @return void
47      */
48     public function test_ml_training_and_prediction($timesplittingid, $npredictedranges, $predictionsprocessorclass) {
49         global $DB;
51         $ncourses = 10;
53         $this->resetAfterTest(true);
55         // Generate training data.
56         $params = array(
57             'startdate' => mktime(0, 0, 0, 10, 24, 2015),
58             'enddate' => mktime(0, 0, 0, 2, 24, 2016),
59         );
60         for ($i = 0; $i < $ncourses; $i++) {
61             $name = 'a' . random_string(10);
62             $courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
63             $this->getDataGenerator()->create_course($courseparams);
64         }
65         for ($i = 0; $i < $ncourses; $i++) {
66             $name = 'b' . random_string(10);
67             $courseparams = array('shortname' => $name, 'fullname' => $name) + $params;
68             $this->getDataGenerator()->create_course($courseparams);
69         }
71         // We repeat the test for all prediction processors.
72         $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
73         if ($predictionsprocessor->is_ready() !== true) {
74             $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
75         }
77         set_config('predictionsprocessor', $predictionsprocessorclass, 'analytics');
79         $model = $this->add_perfect_model();
80         $model->enable($timesplittingid);
82         // No samples trained yet.
83         $this->assertEquals(0, $DB->count_records('analytics_train_samples', array('modelid' => $model->get_id())));
85         $results = $model->train();
86         $this->assertEquals(1, $model->get_model_obj()->enabled);
87         $this->assertEquals(1, $model->get_model_obj()->trained);
89         // 1 training file was created.
90         $trainedsamples = $DB->get_records('analytics_train_samples', array('modelid' => $model->get_id()));
91         $this->assertEquals(1, count($trainedsamples));
92         $samples = json_decode(reset($trainedsamples)->sampleids, true);
93         $this->assertEquals($ncourses * 2, count($samples));
94         $this->assertEquals(1, $DB->count_records('analytics_used_files',
95             array('modelid' => $model->get_id(), 'action' => 'trained')));
97         // Now we create 2 hidden courses (they should not be used for training by the target).
98         $courseparams = $params + array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0);
99         $course1 = $this->getDataGenerator()->create_course($courseparams);
100         $courseparams = $params + array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0);
101         $course2 = $this->getDataGenerator()->create_course($courseparams);
103         // No more files should be created as the 2 new courses should be skipped by the target (not ready for training).
104         $results = $model->train();
105         $trainedsamples = $DB->get_records('analytics_train_samples', array('modelid' => $model->get_id()));
106         $this->assertEquals(1, count($trainedsamples));
107         $this->assertEquals(1, $DB->count_records('analytics_used_files',
108             array('modelid' => $model->get_id(), 'action' => 'trained')));
110         // They will not be skipped for prediction though.
111         $result = $model->predict();
113         // $course1 predictions should be 1 == 'a', $course2 predictions should be 0 == 'b'.
114         $correct = array($course1->id => 1, $course2->id => 0);
115         foreach ($result->predictions as $uniquesampleid => $predictiondata) {
116             list($sampleid, $rangeindex) = $model->get_time_splitting()->infer_sample_info($uniquesampleid);
118             // The range index is not important here, both ranges prediction will be the same.
119             $this->assertEquals($correct[$sampleid], $predictiondata->prediction);
120         }
122         // 2 ranges will be predicted.
123         $trainedsamples = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
124         $this->assertEquals($npredictedranges, count($trainedsamples));
125         $this->assertEquals(1, $DB->count_records('analytics_used_files',
126             array('modelid' => $model->get_id(), 'action' => 'predicted')));
127         // 2 predictions for each range.
128         $this->assertEquals(2 * $npredictedranges, $DB->count_records('analytics_predictions', array('modelid' => $model->get_id())));
130         // No new generated files nor records as there are no new courses available.
131         $model->predict();
132         $trainedsamples = $DB->get_records('analytics_predict_ranges', array('modelid' => $model->get_id()));
133         $this->assertEquals($npredictedranges, count($trainedsamples));
134         $this->assertEquals(1, $DB->count_records('analytics_used_files',
135             array('modelid' => $model->get_id(), 'action' => 'predicted')));
136         $this->assertEquals(2 * $npredictedranges, $DB->count_records('analytics_predictions', array('modelid' => $model->get_id())));
137     }
139     public function provider_ml_training_and_prediction() {
140         $cases = array(
141             'no_splitting' => array('\core_analytics\local\time_splitting\no_splitting', 1),
142             'quarters' => array('\core_analytics\local\time_splitting\quarters', 4)
143         );
145         // We need to test all system prediction processors.
146         return $this->add_prediction_processors($cases);
147     }
150     /**
151      * Basic test to check that prediction processors work as expected.
152      *
153      * @dataProvider provider_ml_test_evaluation
154      */
155     public function test_ml_evaluation($modelquality, $ncourses, $expected, $predictionsprocessorclass) {
156         $this->resetAfterTest(true);
158         $sometimesplittings = '\core_analytics\local\time_splitting\weekly,' .
159             '\core_analytics\local\time_splitting\single_range,' .
160             '\core_analytics\local\time_splitting\quarters';
161         set_config('timesplittings', $sometimesplittings, 'analytics');
163         if ($modelquality === 'perfect') {
164             $model = $this->add_perfect_model();
165         } else if ($modelquality === 'random') {
166             $model = $this->add_random_model();
167         } else {
168             throw new \coding_exception('Only perfect and random accepted as $modelquality values');
169         }
172         // Generate training data.
173         $params = array(
174             'startdate' => mktime(0, 0, 0, 10, 24, 2015),
175             'enddate' => mktime(0, 0, 0, 2, 24, 2016),
176         );
177         for ($i = 0; $i < $ncourses; $i++) {
178             $name = 'a' . random_string(10);
179             $params = array('shortname' => $name, 'fullname' => $name) + $params;
180             $this->getDataGenerator()->create_course($params);
181         }
182         for ($i = 0; $i < $ncourses; $i++) {
183             $name = 'b' . random_string(10);
184             $params = array('shortname' => $name, 'fullname' => $name) + $params;
185             $this->getDataGenerator()->create_course($params);
186         }
188         // We repeat the test for all prediction processors.
189         $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
190         if ($predictionsprocessor->is_ready() !== true) {
191             $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
192         }
194         set_config('predictionsprocessor', $predictionsprocessorclass, 'analytics');
196         $results = $model->evaluate();
198         // We check that the returned status includes at least $expectedcode code.
199         foreach ($results as $timesplitting => $result) {
200             $message = 'The returned status code ' . $result->status . ' should include ' . $expected[$timesplitting];
201             $this->assertEquals($expected[$timesplitting], $result->status & $expected[$timesplitting], $message);
202         }
203     }
205     public function provider_ml_test_evaluation() {
207         $cases = array(
208             'bad-and-no-enough-data' => array(
209                 'modelquality' => 'random',
210                 'ncourses' => 5,
211                 'expectedresults' => array(
212                     // The course duration is too much to be processed by in weekly basis.
213                     '\core_analytics\local\time_splitting\weekly' => \core_analytics\model::NO_DATASET,
214                     // 10 samples is not enough to process anything.
215                     '\core_analytics\local\time_splitting\single_range' => \core_analytics\model::EVALUATE_NOT_ENOUGH_DATA + \core_analytics\model::EVALUATE_LOW_SCORE,
216                     '\core_analytics\local\time_splitting\quarters' => \core_analytics\model::EVALUATE_NOT_ENOUGH_DATA + \core_analytics\model::EVALUATE_LOW_SCORE,
217                 )
218             ),
219             'bad' => array(
220                 'modelquality' => 'random',
221                 'ncourses' => 50,
222                 'expectedresults' => array(
223                     // The course duration is too much to be processed by in weekly basis.
224                     '\core_analytics\local\time_splitting\weekly' => \core_analytics\model::NO_DATASET,
225                     '\core_analytics\local\time_splitting\single_range' => \core_analytics\model::EVALUATE_LOW_SCORE,
226                     '\core_analytics\local\time_splitting\quarters' => \core_analytics\model::EVALUATE_LOW_SCORE,
227                 )
228             ),
229             'good' => array(
230                 'modelquality' => 'perfect',
231                 'ncourses' => 50,
232                 'expectedresults' => array(
233                     // The course duration is too much to be processed by in weekly basis.
234                     '\core_analytics\local\time_splitting\weekly' => \core_analytics\model::NO_DATASET,
235                     '\core_analytics\local\time_splitting\single_range' => \core_analytics\model::OK,
236                     '\core_analytics\local\time_splitting\quarters' => \core_analytics\model::OK,
237                 )
238             )
239         );
240         return $this->add_prediction_processors($cases);
241     }
243     protected function add_random_model() {
245         $target = \core_analytics\manager::get_target('test_target_shortname');
246         $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_random');
247         foreach ($indicators as $key => $indicator) {
248             $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
249         }
251         $model = \core_analytics\model::create($target, $indicators);
253         // To load db defaults as well.
254         return new \core_analytics\model($model->get_id());
255     }
257     protected function add_perfect_model() {
259         $target = \core_analytics\manager::get_target('test_target_shortname');
260         $indicators = array('test_indicator_max', 'test_indicator_min', 'test_indicator_fullname');
261         foreach ($indicators as $key => $indicator) {
262             $indicators[$key] = \core_analytics\manager::get_indicator($indicator);
263         }
265         $model = \core_analytics\model::create($target, $indicators);
267         // To load db defaults as well.
268         return new \core_analytics\model($model->get_id());
269     }
271     protected function add_prediction_processors($cases) {
273         $return = array();
275         // We need to test all system prediction processors.
276         $predictionprocessors = \core_analytics\manager::get_all_prediction_processors();
277         foreach ($predictionprocessors as $classfullname => $unused) {
278             foreach ($cases as $key => $case) {
279                 $newkey = $key . '-' . $classfullname;
280                 $return[$newkey] = $case + array('predictionsprocessorclass' => $classfullname);
281             }
282         }
284         return $return;
285     }