���ѧۧݧ�ӧ�� �ާ֧ߧ֧էا֧� - ���֧էѧܧ�ڧ��ӧѧ�� - /home3/cpr76684/public_html/statistics.tar
���ѧ٧ѧ�
responses/analysis_for_question_all_tries.php 0000644 00000007016 15152036342 0015761 0 ustar 00 <?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/>. /** * This file contains a class to analyse all the responses for multiple tries at a particular question. * * @package core_question * @copyright 2014 Open University * @author Jamie Pratt <me@jamiep.org> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; /** * Analysis for possible responses for parts of a question with multiple submitted responses. * * If the analysis was for a single try it would be handled by {@link \core_question\statistics\responses\analysis_for_question}. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_question_all_tries extends analysis_for_question{ /** * Constructor. * * @param int $variantno variant number * @param \array[] $responsepartsforeachtry for question with multiple tries we expect an array with first index being try no * then second index is subpartid and values are \question_classified_response */ public function count_response_parts($variantno, $responsepartsforeachtry) { foreach ($responsepartsforeachtry as $try => $responseparts) { foreach ($responseparts as $subpartid => $responsepart) { $this->get_analysis_for_subpart($variantno, $subpartid)->count_response($responsepart, $try); } } } public function has_multiple_tries_data() { return true; } /** * What is the highest number of tries at this question? * * @return int try number */ public function get_maximum_tries() { $max = 1; foreach ($this->get_variant_nos() as $variantno) { foreach ($this->get_subpart_ids($variantno) as $subpartid) { $max = max($max, $this->get_analysis_for_subpart($variantno, $subpartid)->get_maximum_tries()); } } return $max; } } responses/analysis_for_question.php 0000644 00000021140 15152036342 0013715 0 ustar 00 <?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/>. /** * This file contains the code to analyse all the responses to a particular * question. * * @package core_question * @copyright 2013 Open University * @author Jamie Pratt <me@jamiep.org> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; defined('MOODLE_INTERNAL') || die(); /** * Analysis for possible responses for parts of a question. It is up to a question type designer to decide on how many parts their * question has. See {@link \question_type::get_possible_responses()} and sub classes where the sub parts and response classes are * defined. * * A sub part might represent a sub question embedded in the question for example in a matching question there are * several sub parts. A numeric question with a unit might be divided into two sub parts for the purposes of response analysis * or the question type designer might decide to treat the answer, both the numeric and unit part, * as a whole for the purposes of response analysis. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_question { /** * Constructor method. * * @param array[] Two index array, first index is unique string for each sub question part, * the second string index is the 'class' that sub-question part can be classified into. * Value in array is instance of {@link \question_possible_response} * This is the return value from {@link \question_type::get_possible_responses()} * see that method for fuller documentation. */ public function __construct(array $possiblereponses = null) { if ($possiblereponses !== null) { $this->possibleresponses = $possiblereponses; } } /** * @var array[] See description above in constructor method. */ protected $possibleresponses = array(); /** * A multidimensional array whose first index is variant no and second index is subpart id, array contents are of type * {@link analysis_for_subpart}. * * @var array[] */ protected $subparts = array(); /** * Initialise data structure for response analysis of one variant. * * @param int $variantno */ protected function initialise_stats_for_variant($variantno) { $this->subparts[$variantno] = array(); foreach ($this->possibleresponses as $subpartid => $classes) { $this->subparts[$variantno][$subpartid] = new analysis_for_subpart($classes); } } /** * Variant nos found in this question's attempt data. * * @return int[] */ public function get_variant_nos() { return array_keys($this->subparts); } /** * Unique ids for sub parts. * * @param int $variantno * @return string[] */ public function get_subpart_ids($variantno) { return array_keys($this->subparts[$variantno]); } /** * Get the response counts etc. for variant $variantno, question sub part $subpartid. * * Or if there is no recorded analysis yet then initialise the data structure for that part of the analysis and return the * initialised analysis objects. * * @param int $variantno * @param string $subpartid id for sub part. * @return analysis_for_subpart */ public function get_analysis_for_subpart($variantno, $subpartid) { if (!isset($this->subparts[$variantno])) { $this->initialise_stats_for_variant($variantno); } if (!isset($this->subparts[$variantno][$subpartid])) { debugging('Unexpected sub-part id ' . $subpartid . ' encountered.'); $this->subparts[$variantno][$subpartid] = new analysis_for_subpart(); } return $this->subparts[$variantno][$subpartid]; } /** * Used to work out what kind of table is needed to display stats. * * @return bool whether this question has (a subpart with) more than one response class. */ public function has_multiple_response_classes() { foreach ($this->get_variant_nos() as $variantno) { foreach ($this->get_subpart_ids($variantno) as $subpartid) { if ($this->get_analysis_for_subpart($variantno, $subpartid)->has_multiple_response_classes()) { return true; } } } return false; } /** * Used to work out what kind of table is needed to display stats. * * @return bool whether this analysis has more than one subpart. */ public function has_subparts() { foreach ($this->get_variant_nos() as $variantno) { if (count($this->get_subpart_ids($variantno)) > 1) { return true; } } return false; } /** * @return bool Does this response analysis include counts for responses for multiple tries of the question? */ public function has_multiple_tries_data() { return false; } /** * What is the highest number of tries at this question? * * @return int always 1 as this class is for analysing only one try. */ public function get_maximum_tries() { return 1; } /** * Takes an array of {@link \question_classified_response} and adds counts of the responses to the sub parts and classes. * * @param int $variantno * @param \question_classified_response[] $responseparts keys are sub-part id. */ public function count_response_parts($variantno, $responseparts) { foreach ($responseparts as $subpartid => $responsepart) { $this->get_analysis_for_subpart($variantno, $subpartid)->count_response($responsepart); } } /** * @param \qubaid_condition $qubaids which question usages have been analysed. * @param string $whichtries which tries have been analysed? * @param int $questionid which question. */ public function cache($qubaids, $whichtries, $questionid) { foreach ($this->get_variant_nos() as $variantno) { foreach ($this->get_subpart_ids($variantno) as $subpartid) { $analysisforsubpart = $this->get_analysis_for_subpart($variantno, $subpartid); $analysisforsubpart->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid); } } } /** * @return bool whether this analysis has a response class with more than one * different actual response, or if the actual response is different from * the model response. */ public function has_actual_responses() { foreach ($this->get_variant_nos() as $variantno) { foreach ($this->get_subpart_ids($variantno) as $subpartid) { if ($this->get_analysis_for_subpart($variantno, $subpartid)->has_actual_responses()) { return true; } } } return false; } } responses/analyser.php 0000644 00000020252 15152036342 0011116 0 ustar 00 <?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/>. /** * This file contains the code to analyse all the responses to a particular question. * * @package core_question * @copyright 2014 Open University * @author Jamie Pratt <me@jamiep.org> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; defined('MOODLE_INTERNAL') || die(); /** * This class can compute, store and cache the analysis of the responses to a particular question. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analyser { /** * @var int When analysing responses and breaking down the count of responses per try, how many columns should we break down * tries into? This is set to 5 columns, any response in a try more than try 5 will be counted in the fifth column. */ const MAX_TRY_COUNTED = 5; /** @var int Time after which responses are automatically reanalysed. */ const TIME_TO_CACHE = 900; // 15 minutes. /** @var object full question data from db. */ protected $questiondata; /** * @var analysis_for_question|analysis_for_question_all_tries */ public $analysis; /** * @var array Two index array first index is unique string for each sub question part, the second string index is the 'class' * that sub-question part can be classified into. * * This is the return value from {@link \question_type::get_possible_responses()} see that method for fuller documentation. */ public $responseclasses = array(); /** * @var bool whether to break down response analysis by variant. This only applies to questions that have variants and is * used to suppress the break down of analysis by variant when there are going to be very many variants. */ protected $breakdownbyvariant; /** * Create a new instance of this class for holding/computing the statistics * for a particular question. * * @param object $questiondata the full question data from the database defining this question. * @param string $whichtries which tries to analyse. */ public function __construct($questiondata, $whichtries = \question_attempt::LAST_TRY) { $this->questiondata = $questiondata; $qtypeobj = \question_bank::get_qtype($this->questiondata->qtype); if ($whichtries != \question_attempt::ALL_TRIES) { $this->analysis = new analysis_for_question($qtypeobj->get_possible_responses($this->questiondata)); } else { $this->analysis = new analysis_for_question_all_tries($qtypeobj->get_possible_responses($this->questiondata)); } $this->breakdownbyvariant = $qtypeobj->break_down_stats_and_response_analysis_by_variant($this->questiondata); } /** * Does the computed analysis have sub parts? * * @return bool whether this analysis has more than one subpart. */ public function has_subparts() { return count($this->responseclasses) > 1; } /** * Does the computed analysis's sub parts have classes? * * @return bool whether this analysis has (a subpart with) more than one response class. */ public function has_response_classes() { foreach ($this->responseclasses as $partclasses) { if (count($partclasses) > 1) { return true; } } return false; } /** * Analyse all the response data for for all the specified attempts at this question. * * @param \qubaid_condition $qubaids which attempts to consider. * @param string $whichtries which tries to analyse. Will be one of * \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @return analysis_for_question */ public function calculate($qubaids, $whichtries = \question_attempt::LAST_TRY) { // Load data. $dm = new \question_engine_data_mapper(); $questionattempts = $dm->load_attempts_at_question($this->questiondata->id, $qubaids); // Analyse it. foreach ($questionattempts as $qa) { $responseparts = $qa->classify_response($whichtries); if ($this->breakdownbyvariant) { $this->analysis->count_response_parts($qa->get_variant(), $responseparts); } else { $this->analysis->count_response_parts(1, $responseparts); } } $this->analysis->cache($qubaids, $whichtries, $this->questiondata->id); return $this->analysis; } /** * Retrieve the computed response analysis from the question_response_analysis table. * * @param \qubaid_condition $qubaids load the analysis of which question usages? * @param string $whichtries load the analysis of which tries? * @return analysis_for_question|boolean analysis or false if no cached analysis found. */ public function load_cached($qubaids, $whichtries) { global $DB; $timemodified = time() - self::TIME_TO_CACHE; // Variable name 'analyses' is the plural of 'analysis'. $responseanalyses = $DB->get_records_select('question_response_analysis', 'hashcode = ? AND whichtries = ? AND questionid = ? AND timemodified > ?', array($qubaids->get_hash_code(), $whichtries, $this->questiondata->id, $timemodified)); if (!$responseanalyses) { return false; } $analysisids = array(); foreach ($responseanalyses as $responseanalysis) { $analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid); $class = $analysisforsubpart->get_response_class($responseanalysis->aid); $class->add_response($responseanalysis->response, $responseanalysis->credit); $analysisids[] = $responseanalysis->id; } list($sql, $params) = $DB->get_in_or_equal($analysisids); $counts = $DB->get_records_select('question_response_count', "analysisid {$sql}", $params); foreach ($counts as $count) { $responseanalysis = $responseanalyses[$count->analysisid]; $analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid); $class = $analysisforsubpart->get_response_class($responseanalysis->aid); $class->set_response_count($responseanalysis->response, $count->try, $count->rcount); } return $this->analysis; } /** * Find time of non-expired analysis in the database. * * @param \qubaid_condition $qubaids check for the analysis of which question usages? * @param string $whichtries check for the analysis of which tries? * @return integer|boolean Time of cached record that matches this qubaid_condition or false if none found. */ public function get_last_analysed_time($qubaids, $whichtries) { global $DB; $timemodified = time() - self::TIME_TO_CACHE; return $DB->get_field_select('question_response_analysis', 'timemodified', 'hashcode = ? AND whichtries = ? AND questionid = ? AND timemodified > ?', array($qubaids->get_hash_code(), $whichtries, $this->questiondata->id, $timemodified), IGNORE_MULTIPLE); } } responses/analysis_for_actual_response.php 0000644 00000014315 15152036342 0015243 0 ustar 00 <?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/>. /** * @package core_question * @copyright 2013 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; /** * The leafs of the analysis data structure. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_actual_response { /** * @var int[] count per try for this response. */ protected $trycount = array(); /** * @var int total count of tries with this response. */ protected $totalcount = 0; /** * @var float grade for this response, normally between 0 and 1. */ protected $fraction; /** * @var string the response as it will be displayed in report. */ protected $response; /** * @param string $response * @param float $fraction */ public function __construct($response, $fraction) { $this->response = $response; $this->fraction = $fraction; } /** * Used to count the occurrences of response sub parts. * * @param int $try the try number, or 0 if only keeping one count, not a count for each try. */ public function increment_count($try = 0) { $this->totalcount++; if ($try != 0) { if ($try > analyser::MAX_TRY_COUNTED) { $try = analyser::MAX_TRY_COUNTED; } if (!isset($this->trycount[$try])) { $this->trycount[$try] = 0; } $this->trycount[$try]++; } } /** * Used to set the count of occurrences of response sub parts, when loading count from cache. * * @param int $try the try number, or 0 if only keeping one count, not a count for each try. * @param int $count */ public function set_count($try, $count) { $this->totalcount = $this->totalcount + $count; $this->trycount[$try] = $count; } /** * Cache analysis for class. * * @param \qubaid_condition $qubaids which question usages have been analysed. * @param string $whichtries which tries have been analysed? * @param int $questionid which question. * @param int $variantno which variant. * @param string $subpartid which sub part is this actual response in? * @param string $responseclassid which response class is this actual response in? */ public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $responseclassid) { global $DB; $row = new \stdClass(); $row->hashcode = $qubaids->get_hash_code(); $row->whichtries = $whichtries; $row->questionid = $questionid; $row->variant = $variantno; $row->subqid = $subpartid; if ($responseclassid === '') { $row->aid = null; } else { $row->aid = $responseclassid; } $row->response = $this->response; $row->credit = $this->fraction; $row->timemodified = time(); $analysisid = $DB->insert_record('question_response_analysis', $row); if ($whichtries === \question_attempt::ALL_TRIES) { foreach ($this->trycount as $try => $count) { $countrow = new \stdClass(); $countrow->try = $try; $countrow->rcount = $count; $countrow->analysisid = $analysisid; $DB->insert_record('question_response_count', $countrow, false); } } else { $countrow = new \stdClass(); $countrow->try = 0; $countrow->rcount = $this->totalcount; $countrow->analysisid = $analysisid; $DB->insert_record('question_response_count', $countrow, false); } } /** * Returns an object with a property for each column of the question response analysis table. * * @param string $partid * @param string $modelresponse * @return object */ public function data_for_question_response_table($partid, $modelresponse) { $rowdata = new \stdClass(); $rowdata->part = $partid; $rowdata->responseclass = $modelresponse; $rowdata->response = $this->response; $rowdata->fraction = $this->fraction; $rowdata->totalcount = $this->totalcount; $rowdata->trycount = $this->trycount; return $rowdata; } /** * What is the highest try number that this response has been seen? * * @return int try number */ public function get_maximum_tries() { return max(array_keys($this->trycount)); } } responses/analysis_for_subpart.php 0000644 00000013626 15152036342 0013540 0 ustar 00 <?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/>. /** * * Data structure to count responses for each of the sub parts of a question. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; /** * Representing the analysis of each of the sub parts of each variant of the question. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_subpart { /** * @var analysis_for_class[] */ protected $responseclasses; /** * Takes an array of possible_responses as returned from {@link \question_type::get_possible_responses()}. * * @param \question_possible_response[] $responseclasses as returned from {@link \question_type::get_possible_responses()}. */ public function __construct(array $responseclasses = null) { if (is_array($responseclasses)) { foreach ($responseclasses as $responseclassid => $responseclass) { $this->responseclasses[$responseclassid] = new analysis_for_class($responseclass, $responseclassid); } } else { $this->responseclasses = []; } } /** * Unique ids for response classes. * * @return string[] */ public function get_response_class_ids() { return array_keys($this->responseclasses); } /** * Get the instance of the class handling the analysis of $classid for this sub part. * * @param string $classid id for response class. * @return analysis_for_class */ public function get_response_class($classid) { if (!isset($this->responseclasses[$classid])) { debugging('Unexpected class id ' . $classid . ' encountered.'); $this->responseclasses[$classid] = new analysis_for_class('[Unknown]', $classid); } return $this->responseclasses[$classid]; } /** * Whether there is more than one response class for responses in this question sub part? * * @return bool Are there? */ public function has_multiple_response_classes() { return count($this->get_response_class_ids()) > 1; } /** * Count a part of a response. * * @param \question_classified_response $subpart * @param int $try the try number or zero if not keeping track of try number */ public function count_response($subpart, $try = 0) { $responseanalysisforclass = $this->get_response_class($subpart->responseclassid); $responseanalysisforclass->count_response($subpart->response, $subpart->fraction, $try); } /** * Cache analysis for sub part. * * @param \qubaid_condition $qubaids which question usages have been analysed. * @param string $whichtries which tries have been analysed? * @param int $questionid which question. * @param int $variantno which variant. * @param string $subpartid which sub part. */ public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid) { foreach ($this->get_response_class_ids() as $responseclassid) { $analysisforclass = $this->get_response_class($responseclassid); $analysisforclass->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $responseclassid); } } /** * Has actual responses different to the model response for this class? * * @return bool whether this analysis has a response class with more than one * different actual response, or if the actual response is different from * the model response. */ public function has_actual_responses() { foreach ($this->get_response_class_ids() as $responseclassid) { if ($this->get_response_class($responseclassid)->has_actual_responses()) { return true; } } return false; } /** * What is the highest try number for this sub part? * * @return int max tries */ public function get_maximum_tries() { $max = 1; foreach ($this->get_response_class_ids() as $responseclassid) { $max = max($max, $this->get_response_class($responseclassid)->get_maximum_tries()); } return $max; } } responses/analysis_for_class.php 0000644 00000017735 15152036342 0013172 0 ustar 00 <?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/>. /** * @package core_question * @copyright 2013 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\responses; /** * Counts a class of responses for this sub part of the question. * * No response is one possible class of response to a question. * * - There is a separate data structure for each question or sub question's analysis * {@link \core_question\statistics\responses\analysis_for_question} * or {@link \core_question\statistics\responses\analysis_for_question_all_tries}. * - There are separate analysis for each variant in this top level instance. * - Then there are class instances representing the analysis of each of the sub parts of each variant of the question. * {@link \core_question\statistics\responses\analysis_for_subpart}. * - Then within the sub part analysis there are response class analysis * {@link \core_question\statistics\responses\analysis_for_class}. * - Then within each class analysis there are analysis for each actual response * {@link \core_question\statistics\responses\analysis_for_actual_response}. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class analysis_for_class { /** * @var string must be unique for each response class within this sub part. */ protected $responseclassid; /** * @var string represent this class in the response analysis table. */ protected $modelresponse; /** @var string the (partial) credit awarded for this responses. */ protected $fraction; /** @var analysis_for_actual_response[] key is the actual response represented as a string as it will be displayed in report. */ protected $actualresponses = array(); /** * Constructor, just an easy way to set the fields. * * @param \question_possible_response $possibleresponse * @param string $responseclassid */ public function __construct($possibleresponse, $responseclassid) { $this->modelresponse = $possibleresponse->responseclass; $this->fraction = $possibleresponse->fraction; $this->responseclassid = $responseclassid; } /** * Keep a count of a response to this question sub part that falls within this class. * * @param string $actualresponse * @param float|null $fraction * @param int $try * @return \core_question\statistics\responses\analysis_for_actual_response */ public function count_response($actualresponse, $fraction, $try) { if (!isset($this->actualresponses[$actualresponse])) { if ($fraction === null) { $fraction = $this->fraction; } $this->add_response($actualresponse, $fraction); } $this->get_response($actualresponse)->increment_count($try); } /** * Cache analysis for class. * * @param \qubaid_condition $qubaids which question usages have been analysed. * @param string $whichtries which tries have been analysed? * @param int $questionid which question. * @param int $variantno which variant. * @param string $subpartid which sub part. */ public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid) { foreach ($this->get_responses() as $response) { $analysisforactualresponse = $this->get_response($response); $analysisforactualresponse->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $this->responseclassid); } } /** * Add an actual response to the data structure. * * @param string $response A string representing the actual response. * @param float $fraction The fraction of grade awarded for this response. */ public function add_response($response, $fraction) { $this->actualresponses[$response] = new analysis_for_actual_response($response, $fraction); } /** * Used when loading cached counts. * * @param string $response * @param int $try the try number, will be zero if not keeping track of try. * @param int $count the count */ public function set_response_count($response, $try, $count) { $this->actualresponses[$response]->set_count($try, $count); } /** * Are there actual responses to sub parts that where classified into this class? * * @return bool whether this analysis has a response class with more than one * different actual response, or if the actual response is different from * the model response. */ public function has_actual_responses() { $actualresponses = $this->get_responses(); if (count($actualresponses) > 1) { return true; } else if (count($actualresponses) === 1) { $singleactualresponse = reset($actualresponses); return (string)$singleactualresponse !== (string)$this->modelresponse; } return false; } /** * Return the data to display in the response analysis table. * * @param bool $responseclasscolumn * @param string $partid * @return object[] */ public function data_for_question_response_table($responseclasscolumn, $partid) { $return = array(); if (count($this->get_responses()) == 0) { $rowdata = new \stdClass(); $rowdata->part = $partid; $rowdata->responseclass = $this->modelresponse; if (!$responseclasscolumn) { $rowdata->response = $this->modelresponse; } else { $rowdata->response = ''; } $rowdata->fraction = $this->fraction; $rowdata->totalcount = 0; $rowdata->trycount = array(); $return[] = $rowdata; } else { foreach ($this->get_responses() as $actualresponse) { $response = $this->get_response($actualresponse); $return[] = $response->data_for_question_response_table($partid, $this->modelresponse); } } return $return; } /** * What is the highest try number that an actual response of this response class has been seen? * * @return int try number */ public function get_maximum_tries() { $max = 1; foreach ($this->get_responses() as $actualresponse) { $max = max($max, $this->get_response($actualresponse)->get_maximum_tries()); } return $max; } /** * Return array of the actual responses to this sub part that were classified into this class. * * @return string[] the actual responses we are counting tries at. */ protected function get_responses() { return array_keys($this->actualresponses); } /** * Get the data structure used to count the responses that match an actual response within this class of responses. * * @param string $response * @return analysis_for_actual_response the instance for keeping count of tries for $response. */ protected function get_response($response) { return $this->actualresponses[$response]; } } questions/calculated_for_subquestion.php 0000644 00000004331 15152036342 0014721 0 ustar 00 <?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/>. /** * Class for storing calculated sub question statistics and intermediate calculation values. * * @package core_question * @copyright 2013 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; defined('MOODLE_INTERNAL') || die(); /** * A class to store calculated stats for a sub question. * * @package core_question * @copyright 2013 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class calculated_for_subquestion extends calculated { public $subquestion = true; /** * @var array What slots is this sub question used in? */ public $usedin = array(); /** * @var bool Have the slots this sub question has been used in got different grades? */ public $differentweights = false; public $negcovar = 0; /** * @var int only set immediately before display in the table. The order of display in the table. */ public $subqdisplayorder; /** * Constructor. * * @param object|null $step the step data for the step that this sub-question was first encountered in. * @param int|null $variant the variant no */ public function __construct($step = null, $variant = null) { if ($step !== null) { $this->questionid = $step->questionid; $this->maxmark = $step->maxmark; } $this->variant = $variant; } } questions/calculator.php 0000644 00000053243 15152036342 0011450 0 ustar 00 <?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/>. /** * Question statistics calculator class. Used in the quiz statistics report but also available for use elsewhere. * * @package core * @subpackage questionbank * @copyright 2013 Open University * @author Jamie Pratt <me@jamiep.org> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; defined('MOODLE_INTERNAL') || die(); /** * This class has methods to compute the question statistics from the raw data. * * @copyright 2013 Open University * @author Jamie Pratt <me@jamiep.org> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class calculator { /** * @var all_calculated_for_qubaid_condition all the stats calculated for slots and sub-questions and variants of those * questions. */ protected $stats; /** * @var float */ protected $sumofmarkvariance = 0; /** * @var array[] keyed by a string representing the pool of questions that this random question draws from. * string as returned from {@link \core_question\statistics\questions\calculated::random_selector_string} */ protected $randomselectors = array(); /** * @var \progress_trace */ protected $progress; /** * @var string The class name of the class to instantiate to store statistics calculated. */ protected $statscollectionclassname = '\core_question\statistics\questions\all_calculated_for_qubaid_condition'; /** * Constructor. * * @param object[] questions to analyze, keyed by slot, also analyses sub questions for random questions. * we expect some extra fields - slot, maxmark and number on the full question data objects. * @param \core\progress\base|null $progress the element to send progress messages to, default is {@link \core\progress\none}. */ public function __construct($questions, $progress = null) { if ($progress === null) { $progress = new \core\progress\none(); } $this->progress = $progress; $this->stats = new $this->statscollectionclassname(); foreach ($questions as $slot => $question) { $this->stats->initialise_for_slot($slot, $question); $this->stats->for_slot($slot)->randomguessscore = $this->get_random_guess_score($question); } } /** * Calculate the stats. * * @param \qubaid_condition $qubaids Which question usages to calculate the stats for? * @return all_calculated_for_qubaid_condition The calculated stats. */ public function calculate($qubaids) { $this->progress->start_progress('', 6); list($lateststeps, $summarks) = $this->get_latest_steps($qubaids); if ($lateststeps) { $this->progress->start_progress('', count($lateststeps), 1); // Compute the statistics of position, and for random questions, work // out which questions appear in which positions. foreach ($lateststeps as $step) { $this->progress->increment_progress(); $israndomquestion = ($step->questionid != $this->stats->for_slot($step->slot)->questionid); $breakdownvariants = !$israndomquestion && $this->stats->for_slot($step->slot)->break_down_by_variant(); // If this is a variant we have not seen before create a place to store stats calculations for this variant. if ($breakdownvariants && !$this->stats->has_slot($step->slot, $step->variant)) { $question = $this->stats->for_slot($step->slot)->question; $this->stats->initialise_for_slot($step->slot, $question, $step->variant); $this->stats->for_slot($step->slot, $step->variant)->randomguessscore = $this->get_random_guess_score($question); } // Step data walker for main question. $this->initial_steps_walker($step, $this->stats->for_slot($step->slot), $summarks, true, $breakdownvariants); // If this is a random question do the calculations for sub question stats. if ($israndomquestion) { if (!$this->stats->has_subq($step->questionid)) { $this->stats->initialise_for_subq($step); } else if ($this->stats->for_subq($step->questionid)->maxmark != $step->maxmark) { $this->stats->for_subq($step->questionid)->differentweights = true; } // If this is a variant of this subq we have not seen before create a place to store stats calculations for it. if (!$this->stats->has_subq($step->questionid, $step->variant)) { $this->stats->initialise_for_subq($step, $step->variant); } $this->initial_steps_walker($step, $this->stats->for_subq($step->questionid), $summarks, false); // Extra stuff we need to do in this loop for subqs to keep track of where they need to be displayed later. $number = $this->stats->for_slot($step->slot)->question->number; $this->stats->for_subq($step->questionid)->usedin[$number] = $number; // Keep track of which random questions are actually selected from each pool of questions that random // questions are pulled from. $randomselectorstring = $this->stats->for_slot($step->slot)->random_selector_string(); if (!isset($this->randomselectors[$randomselectorstring])) { $this->randomselectors[$randomselectorstring] = array(); } $this->randomselectors[$randomselectorstring][$step->questionid] = $step->questionid; } } $this->progress->end_progress(); foreach ($this->randomselectors as $key => $notused) { ksort($this->randomselectors[$key]); $this->randomselectors[$key] = implode(',', $this->randomselectors[$key]); } $this->stats->subquestions = question_load_questions($this->stats->get_all_subq_ids()); // Compute the statistics for sub questions, if there are any. $this->progress->start_progress('', count($this->stats->subquestions), 1); foreach ($this->stats->subquestions as $qid => $subquestion) { $this->progress->increment_progress(); $subquestion->maxmark = $this->stats->for_subq($qid)->maxmark; $this->stats->for_subq($qid)->question = $subquestion; $this->stats->for_subq($qid)->randomguessscore = $this->get_random_guess_score($subquestion); if ($variants = $this->stats->for_subq($qid)->get_variants()) { foreach ($variants as $variant) { $this->stats->for_subq($qid, $variant)->question = $subquestion; $this->stats->for_subq($qid, $variant)->randomguessscore = $this->get_random_guess_score($subquestion); } $this->stats->for_subq($qid)->sort_variants(); } $this->initial_question_walker($this->stats->for_subq($qid)); if ($this->stats->for_subq($qid)->usedin) { sort($this->stats->for_subq($qid)->usedin, SORT_NUMERIC); $this->stats->for_subq($qid)->positions = implode(',', $this->stats->for_subq($qid)->usedin); } else { $this->stats->for_subq($qid)->positions = ''; } } $this->progress->end_progress(); // Finish computing the averages, and put the sub-question data into the // corresponding questions. $slots = $this->stats->get_all_slots(); $totalnumberofslots = count($slots); $maxindex = $totalnumberofslots - 1; $this->progress->start_progress('', $totalnumberofslots, 1); foreach ($slots as $index => $slot) { $this->stats->for_slot($slot)->sort_variants(); $this->progress->increment_progress(); $nextslotindex = $index + 1; $nextslot = ($nextslotindex > $maxindex) ? false : $slots[$nextslotindex]; $this->initial_question_walker($this->stats->for_slot($slot)); // The rest of this loop is to finish working out where randomly selected question stats should be displayed. if ($this->stats->for_slot($slot)->question->qtype == 'random') { $randomselectorstring = $this->stats->for_slot($slot)->random_selector_string(); if ($nextslot && ($randomselectorstring == $this->stats->for_slot($nextslot)->random_selector_string())) { continue; // Next loop iteration. } if (isset($this->randomselectors[$randomselectorstring])) { $this->stats->for_slot($slot)->subquestions = $this->randomselectors[$randomselectorstring]; } } } $this->progress->end_progress(); // Go through the records one more time. $this->progress->start_progress('', count($lateststeps), 1); foreach ($lateststeps as $step) { $this->progress->increment_progress(); $israndomquestion = ($this->stats->for_slot($step->slot)->question->qtype == 'random'); $this->secondary_steps_walker($step, $this->stats->for_slot($step->slot), $summarks); if ($israndomquestion) { $this->secondary_steps_walker($step, $this->stats->for_subq($step->questionid), $summarks); } } $this->progress->end_progress(); $slots = $this->stats->get_all_slots(); $this->progress->start_progress('', count($slots), 1); $sumofcovariancewithoverallmark = 0; foreach ($this->stats->get_all_slots() as $slot) { $this->progress->increment_progress(); $this->secondary_question_walker($this->stats->for_slot($slot)); $this->sumofmarkvariance += $this->stats->for_slot($slot)->markvariance; $covariancewithoverallmark = $this->stats->for_slot($slot)->covariancewithoverallmark; if (null !== $covariancewithoverallmark && $covariancewithoverallmark >= 0) { $sumofcovariancewithoverallmark += sqrt($covariancewithoverallmark); } } $this->progress->end_progress(); $subqids = $this->stats->get_all_subq_ids(); $this->progress->start_progress('', count($subqids), 1); foreach ($subqids as $subqid) { $this->progress->increment_progress(); $this->secondary_question_walker($this->stats->for_subq($subqid)); } $this->progress->end_progress(); foreach ($this->stats->get_all_slots() as $slot) { if ($sumofcovariancewithoverallmark) { if ($this->stats->for_slot($slot)->negcovar) { $this->stats->for_slot($slot)->effectiveweight = null; } else { $this->stats->for_slot($slot)->effectiveweight = 100 * sqrt($this->stats->for_slot($slot)->covariancewithoverallmark) / $sumofcovariancewithoverallmark; } } else { $this->stats->for_slot($slot)->effectiveweight = null; } } $this->stats->cache($qubaids); // All finished. $this->progress->end_progress(); } return $this->stats; } /** * Used when computing Coefficient of Internal Consistency by quiz statistics. * * @return float */ public function get_sum_of_mark_variance() { return $this->sumofmarkvariance; } /** * Get the latest step data from the db, from which we will calculate stats. * * @param \qubaid_condition $qubaids Which question usages to get the latest steps for? * @return array with two items * - $lateststeps array of latest step data for the question usages * - $summarks array of total marks for each usage, indexed by usage id */ protected function get_latest_steps($qubaids) { $dm = new \question_engine_data_mapper(); $fields = " qas.id, qa.questionusageid, qa.questionid, qa.variant, qa.slot, qa.maxmark, qas.fraction * qa.maxmark as mark"; $lateststeps = $dm->load_questions_usages_latest_steps($qubaids, $this->stats->get_all_slots(), $fields); $summarks = array(); if ($lateststeps) { foreach ($lateststeps as $step) { if (!isset($summarks[$step->questionusageid])) { $summarks[$step->questionusageid] = 0; } $summarks[$step->questionusageid] += $step->mark; } } return array($lateststeps, $summarks); } /** * Calculating the stats is a four step process. * * We loop through all 'last step' data first. * * Update $stats->totalmarks, $stats->markarray, $stats->totalothermarks * and $stats->othermarksarray to include another state. * * @param object $step the state to add to the statistics. * @param calculated $stats the question statistics we are accumulating. * @param array $summarks of the sum of marks for each question usage, indexed by question usage id * @param bool $positionstat whether this is a statistic of position of question. * @param bool $dovariantalso do we also want to do the same calculations for this variant? */ protected function initial_steps_walker($step, $stats, $summarks, $positionstat = true, $dovariantalso = true) { $stats->s++; $stats->totalmarks += $step->mark; $stats->markarray[] = $step->mark; if ($positionstat) { $stats->totalothermarks += $summarks[$step->questionusageid] - $step->mark; $stats->othermarksarray[] = $summarks[$step->questionusageid] - $step->mark; } else { $stats->totalothermarks += $summarks[$step->questionusageid]; $stats->othermarksarray[] = $summarks[$step->questionusageid]; } if ($dovariantalso) { $this->initial_steps_walker($step, $stats->variantstats[$step->variant], $summarks, $positionstat, false); } } /** * Then loop through all questions for the first time. * * Perform some computations on the per-question statistics calculations after * we have been through all the step data. * * @param calculated $stats question stats to update. */ protected function initial_question_walker($stats) { if ($stats->s != 0) { $stats->markaverage = $stats->totalmarks / $stats->s; $stats->othermarkaverage = $stats->totalothermarks / $stats->s; $stats->summarksaverage = $stats->totalsummarks / $stats->s; } else { $stats->markaverage = 0; $stats->othermarkaverage = 0; $stats->summarksaverage = 0; } if ($stats->maxmark != 0) { $stats->facility = $stats->markaverage / $stats->maxmark; } else { $stats->facility = null; } sort($stats->markarray, SORT_NUMERIC); sort($stats->othermarksarray, SORT_NUMERIC); // Here we have collected enough data to make the decision about which questions have variants whose stats we also want to // calculate. We delete the initialised structures where they are not needed. if (!$stats->get_variants() || !$stats->break_down_by_variant()) { $stats->clear_variants(); } foreach ($stats->get_variants() as $variant) { $this->initial_question_walker($stats->variantstats[$variant]); } } /** * Loop through all last step data again. * * Now we know the averages, accumulate the date needed to compute the higher * moments of the question scores. * * @param object $step the state to add to the statistics. * @param calculated $stats the question statistics we are accumulating. * @param float[] $summarks of the sum of marks for each question usage, indexed by question usage id */ protected function secondary_steps_walker($step, $stats, $summarks) { $markdifference = $step->mark - $stats->markaverage; if ($stats->subquestion) { $othermarkdifference = $summarks[$step->questionusageid] - $stats->othermarkaverage; } else { $othermarkdifference = $summarks[$step->questionusageid] - $step->mark - $stats->othermarkaverage; } $overallmarkdifference = $summarks[$step->questionusageid] - $stats->summarksaverage; $sortedmarkdifference = array_shift($stats->markarray) - $stats->markaverage; $sortedothermarkdifference = array_shift($stats->othermarksarray) - $stats->othermarkaverage; $stats->markvariancesum += pow($markdifference, 2); $stats->othermarkvariancesum += pow($othermarkdifference, 2); $stats->covariancesum += $markdifference * $othermarkdifference; $stats->covariancemaxsum += $sortedmarkdifference * $sortedothermarkdifference; $stats->covariancewithoverallmarksum += $markdifference * $overallmarkdifference; if (isset($stats->variantstats[$step->variant])) { $this->secondary_steps_walker($step, $stats->variantstats[$step->variant], $summarks); } } /** * And finally loop through all the questions again. * * Perform more per-question statistics calculations. * * @param calculated $stats question stats to update. */ protected function secondary_question_walker($stats) { if ($stats->s > 1) { $stats->markvariance = $stats->markvariancesum / ($stats->s - 1); $stats->othermarkvariance = $stats->othermarkvariancesum / ($stats->s - 1); $stats->covariance = $stats->covariancesum / ($stats->s - 1); $stats->covariancemax = $stats->covariancemaxsum / ($stats->s - 1); $stats->covariancewithoverallmark = $stats->covariancewithoverallmarksum / ($stats->s - 1); $stats->sd = sqrt($stats->markvariancesum / ($stats->s - 1)); if ($stats->covariancewithoverallmark >= 0) { $stats->negcovar = 0; } else { $stats->negcovar = 1; } } else { $stats->markvariance = null; $stats->othermarkvariance = null; $stats->covariance = null; $stats->covariancemax = null; $stats->covariancewithoverallmark = null; $stats->sd = null; $stats->negcovar = 0; } if ($stats->markvariance * $stats->othermarkvariance) { $stats->discriminationindex = 100 * $stats->covariance / sqrt($stats->markvariance * $stats->othermarkvariance); } else { $stats->discriminationindex = null; } if ($stats->covariancemax) { $stats->discriminativeefficiency = 100 * $stats->covariance / $stats->covariancemax; } else { $stats->discriminativeefficiency = null; } foreach ($stats->variantstats as $variantstat) { $this->secondary_question_walker($variantstat); } } /** * Given the question data find the average grade that random guesses would get. * * @param object $questiondata the full question object. * @return float the random guess score for this question. */ protected function get_random_guess_score($questiondata) { return \question_bank::get_qtype( $questiondata->qtype, false)->get_random_guess_score($questiondata); } /** * Find time of non-expired statistics in the database. * * @param \qubaid_condition $qubaids Which question usages to look for? * @return int|bool Time of cached record that matches this qubaid_condition or false is non found. */ public function get_last_calculated_time($qubaids) { return $this->stats->get_last_calculated_time($qubaids); } /** * Load cached statistics from the database. * * @param \qubaid_condition $qubaids Which question usages to load the cached stats for? * @return all_calculated_for_qubaid_condition The cached stats. */ public function get_cached($qubaids) { $this->stats->get_cached($qubaids); return $this->stats; } } questions/all_calculated_for_qubaid_condition.php 0000644 00000045752 15152036342 0016517 0 ustar 00 <?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/>. /** * A collection of all the question statistics calculated for an activity instance ie. the stats calculated for slots and * sub-questions and variants of those questions. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; use question_bank; /** * A collection of all the question statistics calculated for an activity instance. * * @package core_question * @copyright 2014 The Open University * @author James Pratt me@jamiep.org * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class all_calculated_for_qubaid_condition { /** @var int Time after which statistics are automatically recomputed. */ const TIME_TO_CACHE = 900; // 15 minutes. /** * @var object[] */ public $subquestions = []; /** * Holds slot (position) stats and stats for variants of questions in slots. * * @var calculated[] */ public $questionstats = array(); /** * Holds sub-question stats and stats for variants of subqs. * * @var calculated_for_subquestion[] */ public $subquestionstats = array(); /** * Set up a calculated_for_subquestion instance ready to store a randomly selected question's stats. * * @param object $step * @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null. */ public function initialise_for_subq($step, $variant = null) { $newsubqstat = new calculated_for_subquestion($step, $variant); if ($variant === null) { $this->subquestionstats[$step->questionid] = $newsubqstat; } else { $this->subquestionstats[$step->questionid]->variantstats[$variant] = $newsubqstat; } } /** * Set up a calculated instance ready to store a slot question's stats. * * @param int $slot * @param object $question * @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null. */ public function initialise_for_slot($slot, $question, $variant = null) { $newqstat = new calculated($question, $slot, $variant); if ($variant === null) { $this->questionstats[$slot] = $newqstat; } else { $this->questionstats[$slot]->variantstats[$variant] = $newqstat; } } /** * Do we have stats for a particular quesitonid (and optionally variant)? * * @param int $questionid The id of the sub question. * @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats. * @return bool whether those stats exist (yet). */ public function has_subq($questionid, $variant = null) { if ($variant === null) { return isset($this->subquestionstats[$questionid]); } else { return isset($this->subquestionstats[$questionid]->variantstats[$variant]); } } /** * Reference for a item stats instance for a questionid and optional variant no. * * @param int $questionid The id of the sub question. * @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats. * @return calculated|calculated_for_subquestion stats instance for a questionid and optional variant no. * Will be a calculated_for_subquestion if no variant specified. * @throws \coding_exception if there is an attempt to respond to a non-existant set of stats. */ public function for_subq($questionid, $variant = null) { if ($variant === null) { if (!isset($this->subquestionstats[$questionid])) { throw new \coding_exception('Reference to unknown question id ' . $questionid); } else { return $this->subquestionstats[$questionid]; } } else { if (!isset($this->subquestionstats[$questionid]->variantstats[$variant])) { throw new \coding_exception('Reference to unknown question id ' . $questionid . ' variant ' . $variant); } else { return $this->subquestionstats[$questionid]->variantstats[$variant]; } } } /** * ids of all randomly selected question for all slots. * * @return int[] An array of all sub-question ids. */ public function get_all_subq_ids() { return array_keys($this->subquestionstats); } /** * All slots nos that stats have been calculated for. * * @return int[] An array of all slot nos. */ public function get_all_slots() { return array_keys($this->questionstats); } /** * Do we have stats for a particular slot (and optionally variant)? * * @param int $slot The slot no. * @param int|null $variant if provided then we want the object which stores a variant of a position's stats. * @return bool whether those stats exist (yet). */ public function has_slot($slot, $variant = null) { if ($variant === null) { return isset($this->questionstats[$slot]); } else { return isset($this->questionstats[$slot]->variantstats[$variant]); } } /** * Get position stats instance for a slot and optional variant no. * * @param int $slot The slot no. * @param int|null $variant if provided then we want the object which stores a variant of a position's stats. * @return calculated|calculated_for_subquestion An instance of the class storing the calculated position stats. * @throws \coding_exception if there is an attempt to respond to a non-existant set of stats. */ public function for_slot($slot, $variant = null) { if ($variant === null) { if (!isset($this->questionstats[$slot])) { throw new \coding_exception('Reference to unknown slot ' . $slot); } else { return $this->questionstats[$slot]; } } else { if (!isset($this->questionstats[$slot]->variantstats[$variant])) { throw new \coding_exception('Reference to unknown slot ' . $slot . ' variant ' . $variant); } else { return $this->questionstats[$slot]->variantstats[$variant]; } } } /** * Load cached statistics from the database. * * @param \qubaid_condition $qubaids Which question usages to load stats for? */ public function get_cached($qubaids) { global $DB; $timemodified = time() - self::TIME_TO_CACHE; $questionstatrecs = $DB->get_records_select('question_statistics', 'hashcode = ? AND timemodified > ?', array($qubaids->get_hash_code(), $timemodified)); $questionids = array(); foreach ($questionstatrecs as $fromdb) { if (is_null($fromdb->variant) && !$fromdb->slot) { $questionids[] = $fromdb->questionid; } } $this->subquestions = question_load_questions($questionids); foreach ($questionstatrecs as $fromdb) { if (is_null($fromdb->variant)) { if ($fromdb->slot) { $this->questionstats[$fromdb->slot]->populate_from_record($fromdb); // Array created in constructor and populated from question. } else { $this->subquestionstats[$fromdb->questionid] = new calculated_for_subquestion(); $this->subquestionstats[$fromdb->questionid]->populate_from_record($fromdb); if (isset($this->subquestions[$fromdb->questionid])) { $this->subquestionstats[$fromdb->questionid]->question = $this->subquestions[$fromdb->questionid]; } else { $this->subquestionstats[$fromdb->questionid]->question = question_bank::get_qtype('missingtype', false)->make_deleted_instance($fromdb->questionid, 1); } } } } // Add cached variant stats to data structure. foreach ($questionstatrecs as $fromdb) { if (!is_null($fromdb->variant)) { if ($fromdb->slot) { $newcalcinstance = new calculated(); $this->questionstats[$fromdb->slot]->variantstats[$fromdb->variant] = $newcalcinstance; $newcalcinstance->question = $this->questionstats[$fromdb->slot]->question; } else { $newcalcinstance = new calculated_for_subquestion(); $this->subquestionstats[$fromdb->questionid]->variantstats[$fromdb->variant] = $newcalcinstance; $newcalcinstance->question = $this->subquestions[$fromdb->questionid]; } $newcalcinstance->populate_from_record($fromdb); } } } /** * Find time of non-expired statistics in the database. * * @param \qubaid_condition $qubaids Which question usages to look for stats for? * @return int|bool Time of cached record that matches this qubaid_condition or false if non found. */ public function get_last_calculated_time($qubaids) { global $DB; $timemodified = time() - self::TIME_TO_CACHE; return $DB->get_field_select('question_statistics', 'timemodified', 'hashcode = ? AND timemodified > ?', array($qubaids->get_hash_code(), $timemodified), IGNORE_MULTIPLE); } /** * Save stats to db. * * @param \qubaid_condition $qubaids Which question usages are we caching the stats of? */ public function cache($qubaids) { foreach ($this->get_all_slots() as $slot) { $this->for_slot($slot)->cache($qubaids); } foreach ($this->get_all_subq_ids() as $subqid) { $this->for_subq($subqid)->cache($qubaids); } } /** * Return all sub-questions used. * * @return \object[] array of questions. */ public function get_sub_questions() { return $this->subquestions; } /** * Return all stats for one slot, stats for the slot itself, and either : * - variants of question * - variants of randomly selected questions * - randomly selected questions * * @param int $slot the slot no * @param bool|int $limitvariants limit number of variants and sub-questions displayed? * @return calculated|calculated_for_subquestion[] stats to display */ public function structure_analysis_for_one_slot($slot, $limitvariants = false) { return array_merge(array($this->for_slot($slot)), $this->all_subq_and_variant_stats_for_slot($slot, $limitvariants)); } /** * Call after calculations to output any error messages. * * @return string[] Array of strings describing error messages found during stats calculation. */ public function any_error_messages() { $errors = array(); foreach ($this->get_all_slots() as $slot) { foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) { if ($this->for_subq($subqid)->differentweights) { $name = $this->for_subq($subqid)->question->name; $errors[] = get_string('erroritemappearsmorethanoncewithdifferentweight', 'question', $name); } } } return $errors; } /** * Return all stats for variants of question in slot $slot. * * @param int $slot The slot no. * @return calculated[] The instances storing the calculated stats. */ protected function all_variant_stats_for_one_slot($slot) { $toreturn = array(); foreach ($this->for_slot($slot)->get_variants() as $variant) { $toreturn[] = $this->for_slot($slot, $variant); } return $toreturn; } /** * Return all stats for variants of randomly selected questions for one slot $slot. * * @param int $slot The slot no. * @return calculated[] The instances storing the calculated stats. */ protected function all_subq_variants_for_one_slot($slot) { $toreturn = array(); $displayorder = 1; foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) { if ($variants = $this->for_subq($subqid)->get_variants()) { foreach ($variants as $variant) { $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid, $variant); } } $displayorder++; } return $toreturn; } /** * Return all stats for randomly selected questions for one slot $slot. * * @param int $slot The slot no. * @return calculated[] The instances storing the calculated stats. */ protected function all_subqs_for_one_slot($slot) { $displayorder = 1; $toreturn = array(); foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) { $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid); $displayorder++; } return $toreturn; } /** * Return all variant or 'sub-question' stats one slot, either : * - variants of question * - variants of randomly selected questions * - randomly selected questions * * @param int $slot the slot no * @param bool $limited limit number of variants and sub-questions displayed? * @return calculated|calculated_for_subquestion|calculated_question_summary[] stats to display */ protected function all_subq_and_variant_stats_for_slot($slot, $limited) { // Random question in this slot? if ($this->for_slot($slot)->get_sub_question_ids()) { $toreturn = array(); if ($limited) { $randomquestioncalculated = $this->for_slot($slot); if ($subqvariantstats = $this->all_subq_variants_for_one_slot($slot)) { // There are some variants from randomly selected questions. // If we're showing a limited view of the statistics then add a question summary stat // rather than a stat for each subquestion. $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqvariantstats); $toreturn = array_merge($toreturn, [$summarystat]); } if ($subqstats = $this->all_subqs_for_one_slot($slot)) { // There are some randomly selected questions. // If we're showing a limited view of the statistics then add a question summary stat // rather than a stat for each subquestion. $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqstats); $toreturn = array_merge($toreturn, [$summarystat]); } foreach ($toreturn as $index => $calculated) { $calculated->subqdisplayorder = $index; } } else { $displaynumber = 1; foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) { $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid); if ($variants = $this->for_subq($subqid)->get_variants()) { foreach ($variants as $variant) { $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant); } } $displaynumber++; } } return $toreturn; } else { $variantstats = $this->all_variant_stats_for_one_slot($slot); if ($limited && $variantstats) { $variantquestioncalculated = $this->for_slot($slot); // If we're showing a limited view of the statistics then add a question summary stat // rather than a stat for each variation. $summarystat = $this->make_new_calculated_question_summary_stat($variantquestioncalculated, $variantstats); return [$summarystat]; } else { return $variantstats; } } } /** * We need a new object for display. Sub-question stats can appear more than once in different slots. * So we create a clone of the object and then we can set properties on the object that are per slot. * * @param int $displaynumber The display number for this sub question. * @param int $slot The slot number. * @param int $subqid The sub question id. * @param null|int $variant The variant no. * @return calculated_for_subquestion The object for display. */ protected function make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant = null) { $slotstat = fullclone($this->for_subq($subqid, $variant)); $slotstat->question->number = $this->for_slot($slot)->question->number; $slotstat->subqdisplayorder = $displaynumber; return $slotstat; } /** * Create a summary calculated object for a calculated question. This is used as a placeholder * to indicate that a calculated question has sub questions or variations to show rather than listing each * subquestion or variation directly. * * @param calculated $randomquestioncalculated The calculated instance for the random question slot. * @param calculated[] $subquestionstats The instances of the calculated stats of the questions that are being summarised. * @return calculated_question_summary */ protected function make_new_calculated_question_summary_stat($randomquestioncalculated, $subquestionstats) { $question = $randomquestioncalculated->question; $slot = $randomquestioncalculated->slot; $calculatedsummary = new calculated_question_summary($question, $slot, $subquestionstats); return $calculatedsummary; } } questions/calculated.php 0000644 00000021316 15152036342 0011414 0 ustar 00 <?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/>. /** * Question statistics calculations class. Used in the quiz statistics report but also available for use elsewhere. * * @package core * @subpackage questionbank * @copyright 2013 Open University * @author Jamie Pratt <me@jamiep.org> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; defined('MOODLE_INTERNAL') || die(); /** * This class is used to return the stats as calculated by {@link \core_question\statistics\questions\calculator} * * @copyright 2013 Open University * @author Jamie Pratt <me@jamiep.org> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class calculated { public $questionid; // These first fields are the final fields cached in the db and shown in reports. // See : http://docs.moodle.org/dev/Quiz_statistics_calculations#Position_statistics . public $slot = null; /** * @var null|integer if this property is not null then this is the stats for a variant of a question or when inherited by * calculated_for_subquestion and not null then this is the stats for a variant of a sub question. */ public $variant = null; /** * @var bool is this a sub question. */ public $subquestion = false; /** * @var string if this stat has been picked as a min, median or maximum facility value then this string says which stat this * is. Prepended to question name for display. */ public $minmedianmaxnotice = ''; /** * @var int total attempts at this question. */ public $s = 0; /** * @var float effective weight of this question. */ public $effectiveweight; /** * @var bool is covariance of this questions mark with other question marks negative? */ public $negcovar; /** * @var float */ public $discriminationindex; /** * @var float */ public $discriminativeefficiency; /** * @var float standard deviation */ public $sd; /** * @var float */ public $facility; /** * @var float max mark achievable for this question. */ public $maxmark; /** * @var string comma separated list of the positions in which this question appears. */ public $positions; /** * @var null|float The average score that students would have got by guessing randomly. Or null if not calculable. */ public $randomguessscore = null; // End of fields in db. protected $fieldsindb = array('questionid', 'slot', 'subquestion', 's', 'effectiveweight', 'negcovar', 'discriminationindex', 'discriminativeefficiency', 'sd', 'facility', 'subquestions', 'maxmark', 'positions', 'randomguessscore', 'variant'); // Fields used for intermediate calculations. public $totalmarks = 0; public $totalothermarks = 0; /** * @var float The total of marks achieved for all positions in all attempts where this item was seen. */ public $totalsummarks = 0; public $markvariancesum = 0; public $othermarkvariancesum = 0; public $covariancesum = 0; public $covariancemaxsum = 0; public $subquestions = ''; public $covariancewithoverallmarksum = 0; public $markarray = array(); public $othermarksarray = array(); public $markaverage; public $othermarkaverage; /** * @var float The average for all attempts, of the sum of the marks for all positions in which this item appeared. */ public $summarksaverage; public $markvariance; public $othermarkvariance; public $covariance; public $covariancemax; public $covariancewithoverallmark; /** * @var object full question data */ public $question; /** * An array of calculated stats for each variant of the question. Even when there is just one variant we still calculate this * data as there is no way to know if there are variants before we have finished going through the attempt data one time. * * @var calculated[] $variants */ public $variantstats = array(); /** * Set if this record has been retrieved from cache. This is the time that the statistics were calculated. * * @var integer */ public $timemodified; /** * Set up a calculated instance ready to store a question's (or a variant of a slot's question's) * stats for one slot in the quiz. * * @param null|object $question * @param null|int $slot * @param null|int $variant */ public function __construct($question = null, $slot = null, $variant = null) { if ($question !== null) { $this->questionid = $question->id; $this->maxmark = $question->maxmark; $this->positions = $question->number; $this->question = $question; } if ($slot !== null) { $this->slot = $slot; } if ($variant !== null) { $this->variant = $variant; } } /** * Used to determine which random questions pull sub questions from the same pools. Where pool means category and possibly * all the sub categories of that category. * * @return null|string represents the pool of questions from which this question draws if it is random, or null if not. */ public function random_selector_string() { if ($this->question->qtype == 'random') { return $this->question->category .'/'. $this->question->questiontext; } else { return null; } } /** * Cache calculated stats stored in this object in 'question_statistics' table. * * @param \qubaid_condition $qubaids */ public function cache($qubaids) { global $DB; $toinsert = new \stdClass(); $toinsert->hashcode = $qubaids->get_hash_code(); $toinsert->timemodified = time(); foreach ($this->fieldsindb as $field) { $toinsert->{$field} = $this->{$field}; } $DB->insert_record('question_statistics', $toinsert, false); if ($this->get_variants()) { foreach ($this->variantstats as $variantstat) { $variantstat->cache($qubaids); } } } /** * Load properties of this class from db record. * * @param object $record Given a record from 'question_statistics' copy stats from record to properties. */ public function populate_from_record($record) { foreach ($this->fieldsindb as $field) { $this->$field = $record->$field; } $this->timemodified = $record->timemodified; } /** * Sort the variants of this question by variant number. */ public function sort_variants() { ksort($this->variantstats); } /** * Get any sub question ids for this question. * * @return int[] array of sub-question ids or empty array if there are none. */ public function get_sub_question_ids() { if ($this->subquestions !== '') { return explode(',', $this->subquestions); } else { return array(); } } /** * Array of variants that have appeared in the attempt data for this question. Or an empty array if there is only one variant. * * @return int[] the variant nos. */ public function get_variants() { $variants = array_keys($this->variantstats); if (count($variants) > 1 || reset($variants) != 1) { return $variants; } else { return array(); } } /** * Do we break down the stats for this question by variant or not? * * @return bool Do we? */ public function break_down_by_variant() { $qtype = \question_bank::get_qtype($this->question->qtype); return $qtype->break_down_stats_and_response_analysis_by_variant($this->question); } /** * Delete the data structure for storing variant stats. */ public function clear_variants() { $this->variantstats = array(); } } questions/calculated_question_summary.php 0000644 00000013734 15152036342 0015125 0 ustar 00 <?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/>. /** * Question statistics calculations class. Used in the quiz statistics report. * * @package core_question * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace core_question\statistics\questions; defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/lib.php'); /** * Class calculated_question_summary * * This class is used to indicate the statistics for a random question slot should * be rendered with a link to a summary of the displayed questions. * * It's used in the limited view of the statistics calculation in lieu of adding * the stats for each subquestion individually. * * @copyright 2018 Ryan Wyllie <ryan@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class calculated_question_summary extends calculated { /** * @var int only set immediately before display in the table. The order of display in the table. */ public $subqdisplayorder; /** * @var calculated[] The instances storing the calculated stats of the questions that are being summarised. */ protected $subqstats; /** * calculated_question_summary constructor. * * @param \stdClass $question * @param int $slot * @param calculated[] $subqstats The instances of the calculated stats of the questions that are being summarised. */ public function __construct($question, $slot, $subqstats) { parent::__construct($question, $slot); $this->subqstats = $subqstats; $this->subquestions = implode(',', array_column($subqstats, 'questionid')); } /** * This is a summary stat so never breakdown by variant. * * @return bool */ public function break_down_by_variant() { return false; } /** * Returns the minimum and maximum values of the given attribute in the summarised calculated stats. * * @param string $attribute The attribute that we are looking for its extremums. * @return array An array of [min,max] */ public function get_min_max_of($attribute) { $getmethod = 'get_min_max_of_' . $attribute; if (method_exists($this, $getmethod)) { return $this->$getmethod(); } else { $min = $max = null; $set = false; // We cannot simply use min or max functions because, in theory, some attributes might be non-scalar. foreach (array_column($this->subqstats, $attribute) as $value) { if (is_scalar($value) || is_null($value)) { if (!$set) { // It is not good enough to check if (!isset($min)), // because $min might have been set to null in an earlier iteration. $min = $value; $max = $value; $set = true; } $min = $this->min($min, $value); $max = $this->max($max, $value); } } return [$min, $max]; } } /** * Returns the minimum and maximum values of the standard deviation in the summarised calculated stats. * @return array An array of [min,max] */ protected function get_min_max_of_sd() { $min = $max = null; $set = false; foreach ($this->subqstats as $subqstat) { if (isset($subqstat->sd) && $subqstat->maxmark > \question_utils::MARK_TOLERANCE) { $value = $subqstat->sd / $subqstat->maxmark; } else { $value = null; } if (!$set) { // It is not good enough to check if (!isset($min)), // because $min might have been set to null in an earlier iteration. $min = $value; $max = $value; $set = true; } $min = $this->min($min, $value); $max = $this->max($max, $value); } return [$min, $max]; } /** * Find higher value. * A zero value is almost considered equal to zero in comparisons. The only difference is that when being compared to zero, * zero is higher than null. * * @param float|null $value1 * @param float|null $value2 * @return float|null */ protected function max(float $value1 = null, float $value2 = null) { $temp1 = $value1 ?: 0; $temp2 = $value2 ?: 0; $tempmax = max($temp1, $temp2); if (!$tempmax && $value1 !== 0 && $value2 !== 0) { $max = null; } else { $max = $tempmax; } return $max; } /** * Find lower value. * A zero value is almost considered equal to zero in comparisons. The only difference is that when being compared to zero, * zero is lower than null. * * @param float|null $value1 * @param float|null $value2 * @return mixed|null */ protected function min(float $value1 = null, float $value2 = null) { $temp1 = $value1 ?: 0; $temp2 = $value2 ?: 0; $tempmin = min($temp1, $temp2); if (!$tempmin && $value1 !== 0 && $value2 !== 0) { $min = null; } else { $min = $tempmin; } return $min; } } tests/helper_test.php 0000644 00000066317 15152217600 0010751 0 ustar 00 <?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 qbank_statistics; use core_question\statistics\questions\all_calculated_for_qubaid_condition; use quiz; use question_engine; use quiz_attempt; /** * Tests for question statistics. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper_test extends \advanced_testcase { /** * Test quizzes that contain a specified question. * * @covers ::get_all_places_where_questions_were_attempted * @throws \coding_exception * @throws \dml_exception */ public function test_get_all_places_where_questions_were_attempted(): void { $this->resetAfterTest(); $this->setAdminUser(); $rcm = new \ReflectionMethod(helper::class, 'get_all_places_where_questions_were_attempted'); $rcm->setAccessible(true); // Create a course. $course = $this->getDataGenerator()->create_course(); // Create three quizzes. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $quiz1 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,2,0' ]); $quiz1context = \context_module::instance($quiz1->cmid); $quiz2 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,2,0' ]); $quiz2context = \context_module::instance($quiz2->cmid); $quiz3 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => '1,2,0' ]); $quiz3context = \context_module::instance($quiz3->cmid); // Create questions. /** @var \core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $question1 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); $question2 = $questiongenerator->create_question('numerical', null, array('category' => $cat->id)); // Add question 1 to quiz 1 and make an attempt. quiz_add_quiz_question($question1->id, $quiz1); // Quiz 1 attempt. $this->submit_quiz($quiz1, [1 => ['answer' => 'frog']]); // Add questions 1 and 2 to quiz 2. quiz_add_quiz_question($question1->id, $quiz2); quiz_add_quiz_question($question2->id, $quiz2); $this->submit_quiz($quiz2, [1 => ['answer' => 'frog'], 2 => ['answer' => 10]]); // Checking quizzes that use question 1. $q1places = $rcm->invoke(null, [$question1->id]); $this->assertCount(2, $q1places); $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz1context->id], $q1places[0]); $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q1places[1]); // Checking quizzes that contain question 2. $q2places = $rcm->invoke(null, [$question2->id]); $this->assertCount(1, $q2places); $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q2places[0]); // Add a random question to quiz3. quiz_add_random_questions($quiz3, 0, $cat->id, 1, false); $this->submit_quiz($quiz3, [1 => ['answer' => 'willbewrong']]); // Quiz 3 will now be in one of these arrays. $q1places = $rcm->invoke(null, [$question1->id]); $q2places = $rcm->invoke(null, [$question2->id]); if (count($q1places) == 3) { $newplace = end($q1places); } else { $newplace = end($q2places); } $this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz3context->id], $newplace); } /** * Create 2 quizzes. * * @return array return 2 quizzes * @throws \coding_exception */ private function prepare_quizzes(): array { // Create a course. $course = $this->getDataGenerator()->create_course(); // Make 2 quizzes. $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); $layout = '1,2,0,3,4,0'; $quiz1 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => $layout ]); $quiz2 = $quizgenerator->create_instance([ 'course' => $course->id, 'grade' => 100.0, 'sumgrades' => 2, 'layout' => $layout ]); /** @var \core_question_generator $questiongenerator */ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $cat = $questiongenerator->create_question_category(); $page = 1; $questions = []; foreach (explode(',', $layout) as $slot) { if ($slot == 0) { $page += 1; continue; } $question = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $questions[$slot] = $question; quiz_add_quiz_question($question->id, $quiz1, $page); quiz_add_quiz_question($question->id, $quiz2, $page); } return [$quiz1, $quiz2, $questions]; } /** * Submit quiz answers * * @param object $quiz * @param array $answers * @throws \moodle_exception */ private function submit_quiz(object $quiz, array $answers): void { // Create user. $user = $this->getDataGenerator()->create_user(); // Create attempt. $quizobj = quiz::create($quiz->id, $user->id); $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); $timenow = time(); $attempt = quiz_create_attempt($quizobj, 1, null, $timenow, false, $user->id); quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow); quiz_attempt_save_started($quizobj, $quba, $attempt); // Submit attempt. $attemptobj = quiz_attempt::create($attempt->id); $attemptobj->process_submitted_actions($timenow, false, $answers); $attemptobj->process_finish($timenow, false); } /** * Generate attempt answers. * * @param array $correctanswerflags array of 1 or 0 * 1 : generate correct answer * 0 : generate wrong answer * * @return array */ private function generate_attempt_answers(array $correctanswerflags): array { $attempt = []; for ($i = 1; $i <= 4; $i++) { if (isset($correctanswerflags) && $correctanswerflags[$i - 1] == 1) { // Correct answer. $attempt[$i] = ['answer' => 'frog']; } else { $attempt[$i] = ['answer' => 'false']; } } return $attempt; } /** * * Generate quizzes and submit answers. * * @param array $quiz1attempts quiz 1 attempts * @param array $quiz2attempts quiz 2 attempts * * @return array */ private function prepare_and_submit_quizzes(array $quiz1attempts, array $quiz2attempts): array { list($quiz1, $quiz2, $questions) = $this->prepare_quizzes(); // Submit attempts of quiz1. foreach ($quiz1attempts as $attempt) { $this->submit_quiz($quiz1, $attempt); } // Submit attempts of quiz2. foreach ($quiz2attempts as $attempt) { $this->submit_quiz($quiz2, $attempt); } return [$quiz1, $quiz2, $questions]; } /** * To use private helper::extract_item_value function. * * @param all_calculated_for_qubaid_condition $statistics the batch of statistics. * @param int $questionid a question id. * @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'. * @return float|null the required value. */ private function extract_item_value(all_calculated_for_qubaid_condition $statistics, int $questionid, string $item): ?float { $rcm = new \ReflectionMethod(helper::class, 'extract_item_value'); $rcm->setAccessible(true); return $rcm->invoke(null, $statistics, $questionid, $item); } /** * To use private helper::load_statistics_for_place function (with mod_quiz component). * * @param \context $context the context to load the statistics for. * @return all_calculated_for_qubaid_condition|null question statistics. */ private function load_quiz_statistics_for_place(\context $context): ?all_calculated_for_qubaid_condition { $rcm = new \ReflectionMethod(helper::class, 'load_statistics_for_place'); $rcm->setAccessible(true); return $rcm->invoke(null, 'mod_quiz', $context); } /** * Data provider for {@see test_load_question_facility()}. * * @return \Generator */ public function load_question_facility_provider(): \Generator { yield 'Facility case 1' => [ 'Quiz 1 attempts' => [ $this->generate_attempt_answers([1, 0, 0, 0]), ], 'Expected quiz 1 facilities' => ['100.00%', '0.00%', '0.00%', '0.00%'], 'Quiz 2 attempts' => [ $this->generate_attempt_answers([1, 0, 0, 0]), $this->generate_attempt_answers([1, 1, 0, 0]), ], 'Expected quiz 2 facilities' => ['100.00%', '50.00%', '0.00%', '0.00%'], 'Expected average facilities' => ['100.00%', '25.00%', '0.00%', '0.00%'], ]; yield 'Facility case 2' => [ 'Quiz 1 attempts' => [ $this->generate_attempt_answers([1, 0, 0, 0]), $this->generate_attempt_answers([1, 1, 0, 0]), $this->generate_attempt_answers([1, 1, 1, 0]), ], 'Expected quiz 1 facilities' => ['100.00%', '66.67%', '33.33%', '0.00%'], 'Quiz 2 attempts' => [ $this->generate_attempt_answers([1, 0, 0, 0]), $this->generate_attempt_answers([1, 1, 0, 0]), $this->generate_attempt_answers([1, 1, 1, 0]), $this->generate_attempt_answers([1, 1, 1, 1]), ], 'Expected quiz 2 facilities' => ['100.00%', '75.00%', '50.00%', '25.00%'], 'Expected average facilities' => ['100.00%', '70.83%', '41.67%', '12.50%'], ]; } /** * Test question facility * * @dataProvider load_question_facility_provider * * @param array $quiz1attempts quiz 1 attempts * @param array $expectedquiz1facilities expected quiz 1 facilities * @param array $quiz2attempts quiz 2 attempts * @param array $expectedquiz2facilities expected quiz 2 facilities * @param array $expectedaveragefacilities expected average facilities */ public function test_load_question_facility( array $quiz1attempts, array $expectedquiz1facilities, array $quiz2attempts, array $expectedquiz2facilities, array $expectedaveragefacilities) : void { $this->resetAfterTest(); list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); // Quiz 1 facilities. $stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz1->cmid)); $quiz1facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility'); $quiz1facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility'); $quiz1facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility'); $quiz1facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility'); $this->assertEquals($expectedquiz1facilities[0], helper::format_percentage($quiz1facility1)); $this->assertEquals($expectedquiz1facilities[1], helper::format_percentage($quiz1facility2)); $this->assertEquals($expectedquiz1facilities[2], helper::format_percentage($quiz1facility3)); $this->assertEquals($expectedquiz1facilities[3], helper::format_percentage($quiz1facility4)); // Quiz 2 facilities. $stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz2->cmid)); $quiz2facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility'); $quiz2facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility'); $quiz2facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility'); $quiz2facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility'); $this->assertEquals($expectedquiz2facilities[0], helper::format_percentage($quiz2facility1)); $this->assertEquals($expectedquiz2facilities[1], helper::format_percentage($quiz2facility2)); $this->assertEquals($expectedquiz2facilities[2], helper::format_percentage($quiz2facility3)); $this->assertEquals($expectedquiz2facilities[3], helper::format_percentage($quiz2facility4)); // Average question facilities. $averagefacility1 = helper::calculate_average_question_facility($questions[1]->id); $averagefacility2 = helper::calculate_average_question_facility($questions[2]->id); $averagefacility3 = helper::calculate_average_question_facility($questions[3]->id); $averagefacility4 = helper::calculate_average_question_facility($questions[4]->id); $this->assertEquals($expectedaveragefacilities[0], helper::format_percentage($averagefacility1)); $this->assertEquals($expectedaveragefacilities[1], helper::format_percentage($averagefacility2)); $this->assertEquals($expectedaveragefacilities[2], helper::format_percentage($averagefacility3)); $this->assertEquals($expectedaveragefacilities[3], helper::format_percentage($averagefacility4)); } /** * Data provider for {@see test_load_question_discriminative_efficiency()}. * @return \Generator */ public function load_question_discriminative_efficiency_provider(): \Generator { yield 'Discriminative efficiency' => [ 'Quiz 1 attempts' => [ $this->generate_attempt_answers([1, 0, 0, 0]), $this->generate_attempt_answers([1, 1, 0, 0]), $this->generate_attempt_answers([1, 0, 1, 0]), $this->generate_attempt_answers([1, 1, 1, 1]), ], 'Expected quiz 1 discriminative efficiency' => ['N/A', '33.33%', '33.33%', '100.00%'], 'Quiz 2 attempts' => [ $this->generate_attempt_answers([1, 1, 1, 1]), $this->generate_attempt_answers([0, 0, 0, 0]), $this->generate_attempt_answers([1, 0, 0, 1]), $this->generate_attempt_answers([0, 1, 1, 0]), ], 'Expected quiz 2 discriminative efficiency' => ['50.00%', '50.00%', '50.00%', '50.00%'], 'Expected average discriminative efficiency' => ['50.00%', '41.67%', '41.67%', '75.00%'], ]; } /** * Test discriminative efficiency * * @dataProvider load_question_discriminative_efficiency_provider * * @param array $quiz1attempts quiz 1 attempts * @param array $expectedquiz1discriminativeefficiency expected quiz 1 discriminative efficiency * @param array $quiz2attempts quiz 2 attempts * @param array $expectedquiz2discriminativeefficiency expected quiz 2 discriminative efficiency * @param array $expectedaveragediscriminativeefficiency expected average discriminative efficiency */ public function test_load_question_discriminative_efficiency( array $quiz1attempts, array $expectedquiz1discriminativeefficiency, array $quiz2attempts, array $expectedquiz2discriminativeefficiency, array $expectedaveragediscriminativeefficiency ): void { $this->resetAfterTest(); list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); // Quiz 1 discriminative efficiency. $stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz1->cmid)); $discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency'); $discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency'); $discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency'); $discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency'); $this->assertEquals($expectedquiz1discriminativeefficiency[0], helper::format_percentage($discriminativeefficiency1, false), "Failure in quiz 1 - question 1 discriminative efficiency"); $this->assertEquals($expectedquiz1discriminativeefficiency[1], helper::format_percentage($discriminativeefficiency2, false), "Failure in quiz 1 - question 2 discriminative efficiency"); $this->assertEquals($expectedquiz1discriminativeefficiency[2], helper::format_percentage($discriminativeefficiency3, false), "Failure in quiz 1 - question 3 discriminative efficiency"); $this->assertEquals($expectedquiz1discriminativeefficiency[3], helper::format_percentage($discriminativeefficiency4, false), "Failure in quiz 1 - question 4 discriminative efficiency"); // Quiz 2 discriminative efficiency. $stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz2->cmid)); $discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency'); $discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency'); $discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency'); $discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency'); $this->assertEquals($expectedquiz2discriminativeefficiency[0], helper::format_percentage($discriminativeefficiency1, false), "Failure in quiz 2 - question 1 discriminative efficiency"); $this->assertEquals($expectedquiz2discriminativeefficiency[1], helper::format_percentage($discriminativeefficiency2, false), "Failure in quiz 2 - question 2 discriminative efficiency"); $this->assertEquals($expectedquiz2discriminativeefficiency[2], helper::format_percentage($discriminativeefficiency3, false), "Failure in quiz 2 - question 3 discriminative efficiency"); $this->assertEquals($expectedquiz2discriminativeefficiency[3], helper::format_percentage($discriminativeefficiency4, false), "Failure in quiz 2 - question 4 discriminative efficiency"); // Average question discriminative efficiency. $avgdiscriminativeefficiency1 = helper::calculate_average_question_discriminative_efficiency($questions[1]->id); $avgdiscriminativeefficiency2 = helper::calculate_average_question_discriminative_efficiency($questions[2]->id); $avgdiscriminativeefficiency3 = helper::calculate_average_question_discriminative_efficiency($questions[3]->id); $avgdiscriminativeefficiency4 = helper::calculate_average_question_discriminative_efficiency($questions[4]->id); $this->assertEquals($expectedaveragediscriminativeefficiency[0], helper::format_percentage($avgdiscriminativeefficiency1, false), "Failure in question 1 average discriminative efficiency"); $this->assertEquals($expectedaveragediscriminativeefficiency[1], helper::format_percentage($avgdiscriminativeefficiency2, false), "Failure in question 2 average discriminative efficiency"); $this->assertEquals($expectedaveragediscriminativeefficiency[2], helper::format_percentage($avgdiscriminativeefficiency3, false), "Failure in question 3 average discriminative efficiency"); $this->assertEquals($expectedaveragediscriminativeefficiency[3], helper::format_percentage($avgdiscriminativeefficiency4, false), "Failure in question 4 average discriminative efficiency"); } /** * Data provider for {@see test_load_question_discrimination_index()}. * @return \Generator */ public function load_question_discrimination_index_provider(): \Generator { yield 'Discrimination Index' => [ 'Quiz 1 attempts' => [ $this->generate_attempt_answers([1, 0, 0, 0]), $this->generate_attempt_answers([1, 1, 0, 0]), $this->generate_attempt_answers([1, 0, 1, 0]), $this->generate_attempt_answers([1, 1, 1, 1]), ], 'Expected quiz 1 Discrimination Index' => ['N/A', '30.15%', '30.15%', '81.65%'], 'Quiz 2 attempts' => [ $this->generate_attempt_answers([1, 1, 1, 1]), $this->generate_attempt_answers([0, 0, 0, 0]), $this->generate_attempt_answers([1, 0, 0, 1]), $this->generate_attempt_answers([0, 1, 1, 0]), ], 'Expected quiz 2 discrimination Index' => ['44.72%', '44.72%', '44.72%', '44.72%'], 'Expected average discrimination Index' => ['44.72%', '37.44%', '37.44%', '63.19%'], ]; } /** * Test discrimination index * * @dataProvider load_question_discrimination_index_provider * * @param array $quiz1attempts quiz 1 attempts * @param array $expectedquiz1discriminationindex expected quiz 1 discrimination index * @param array $quiz2attempts quiz 2 attempts * @param array $expectedquiz2discriminationindex expected quiz 2 discrimination index * @param array $expectedaveragediscriminationindex expected average discrimination index */ public function test_load_question_discrimination_index( array $quiz1attempts, array $expectedquiz1discriminationindex, array $quiz2attempts, array $expectedquiz2discriminationindex, array $expectedaveragediscriminationindex ): void { $this->resetAfterTest(); list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts); // Quiz 1 discrimination index. $stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz1->cmid)); $discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex'); $discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex'); $discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex'); $discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex'); $this->assertEquals($expectedquiz1discriminationindex[0], helper::format_percentage($discriminationindex1, false), "Failure in quiz 1 - question 1 discrimination index"); $this->assertEquals($expectedquiz1discriminationindex[1], helper::format_percentage($discriminationindex2, false), "Failure in quiz 1 - question 2 discrimination index"); $this->assertEquals($expectedquiz1discriminationindex[2], helper::format_percentage($discriminationindex3, false), "Failure in quiz 1 - question 3 discrimination index"); $this->assertEquals($expectedquiz1discriminationindex[3], helper::format_percentage($discriminationindex4, false), "Failure in quiz 1 - question 4 discrimination index"); // Quiz 2 discrimination index. $stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz2->cmid)); $discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex'); $discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex'); $discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex'); $discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex'); $this->assertEquals($expectedquiz2discriminationindex[0], helper::format_percentage($discriminationindex1, false), "Failure in quiz 2 - question 1 discrimination index"); $this->assertEquals($expectedquiz2discriminationindex[1], helper::format_percentage($discriminationindex2, false), "Failure in quiz 2 - question 2 discrimination index"); $this->assertEquals($expectedquiz2discriminationindex[2], helper::format_percentage($discriminationindex3, false), "Failure in quiz 2 - question 3 discrimination index"); $this->assertEquals($expectedquiz2discriminationindex[3], helper::format_percentage($discriminationindex4, false), "Failure in quiz 2 - question 4 discrimination index"); // Average question discrimination index. $avgdiscriminationindex1 = helper::calculate_average_question_discrimination_index($questions[1]->id); $avgdiscriminationindex2 = helper::calculate_average_question_discrimination_index($questions[2]->id); $avgdiscriminationindex3 = helper::calculate_average_question_discrimination_index($questions[3]->id); $avgdiscriminationindex4 = helper::calculate_average_question_discrimination_index($questions[4]->id); $this->assertEquals($expectedaveragediscriminationindex[0], helper::format_percentage($avgdiscriminationindex1, false), "Failure in question 1 average discrimination index"); $this->assertEquals($expectedaveragediscriminationindex[1], helper::format_percentage($avgdiscriminationindex2, false), "Failure in question 2 average discrimination index"); $this->assertEquals($expectedaveragediscriminationindex[2], helper::format_percentage($avgdiscriminationindex3, false), "Failure in question 3 average discrimination index"); $this->assertEquals($expectedaveragediscriminationindex[3], helper::format_percentage($avgdiscriminationindex4, false), "Failure in question 4 average discrimination index"); } } tests/behat/statistics_action.feature 0000644 00000003251 15152217600 0014075 0 ustar 00 @qbank @qbank_statistics Feature: Use the qbank plugin manager page for statistics In order to check the plugin behaviour with enable and disable Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "activities" exist: | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | First question | Answer the first question | Scenario: Enable/disable statistics columns from the base view Given I log in as "admin" When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I should see "Question statistics" And I click on "Disable" "link" in the "Question statistics" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page Then I should not see "Facility index" And I should not see "Discriminative efficiency" And I should not see "Needs checking?" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "Question statistics" "table_row" And I am on the "Test quiz" "mod_quiz > question bank" page And I should see "Facility index" And I should see "Discriminative efficiency" And I should see "Needs checking?" tests/behat/statistics_values.feature 0000644 00000013254 15152217600 0014123 0 ustar 00 @qbank @qbank_statistics Feature: Show statistics in question bank Background: Given the following "courses" exist: | fullname | shortname | category | | Course 1 | C1 | 0 | And the following "users" exist: | username | firstname | lastname | email | | student1 | user1 | Student1 | student1@example.com | | student2 | user2 | Student2 | student2@example.com | | student3 | user3 | Student3 | student3@example.com | | student4 | user4 | Student4 | student4@example.com | And the following "course enrolments" exist: | user | course | role | | student1 | C1 | student | | student2 | C1 | student | And the following "question categories" exist: | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | | Test questions | truefalse | TF1 | First question | | Test questions | truefalse | TF2 | Second question | | Test questions | truefalse | TF3 | Third question | | Test questions | truefalse | TF4 | Fourth question | And the following "activities" exist: | activity | name | intro | course | idnumber | | quiz | Quiz 1 | Quiz 1 description | C1 | quiz1 | | quiz | Quiz 2 | Quiz 2 description | C1 | quiz2 | And quiz "Quiz 1" contains the following questions: | question | page | maxmark | | TF1 | 1 | 1.0 | | TF2 | 1 | 1.0 | | TF3 | 1 | 1.0 | | TF4 | 1 | 1.0 | And quiz "Quiz 2" contains the following questions: | question | page | maxmark | | TF2 | 1 | 1.0 | | TF3 | 1 | 1.0 | And user "student1" has attempted "Quiz 1" with responses: | slot | response | | 1 | False | | 2 | False | | 3 | False | | 4 | False | And user "student2" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | True | | 3 | True | | 4 | True | And user "student3" has attempted "Quiz 1" with responses: | slot | response | | 1 | True | | 2 | False | | 3 | False | | 4 | True | And user "student4" has attempted "Quiz 1" with responses: | slot | response | | 1 | False | | 2 | True | | 3 | True | | 4 | False | Scenario: View facility index in question bank Given user "student1" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | And user "student2" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | When I am on the "Course 1" "core_question > course question bank" page logged in as "admin" Then I should see "50.00%" in the "TF1" "table_row" And I should see "75.00%" in the "TF2" "table_row" And I should see "75.00%" in the "TF3" "table_row" And I should see "50.00%" in the "TF4" "table_row" Scenario: View discriminative efficiency in question bank Given user "student1" has attempted "Quiz 2" with responses: | slot | response | | 1 | False | | 2 | False | And user "student2" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | When I am on the "Course 1" "core_question > course question bank" page logged in as "admin" Then I should see "50.00%" in the "TF1" "table_row" And I should see "75.00%" in the "TF2" "table_row" And I should see "75.00%" in the "TF3" "table_row" And I should see "50.00%" in the "TF4" "table_row" Scenario: View discrimination index in question bank, good questions Given user "student1" has attempted "Quiz 2" with responses: | slot | response | | 1 | False | | 2 | False | And user "student2" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | When I am on the "Course 1" "core_question > course question bank" page logged in as "admin" Then I should see "Likely" in the "TF1" "table_row" And I should see "Unlikely" in the "TF2" "table_row" And I should see "Unlikely" in the "TF3" "table_row" And I should see "Likely" in the "TF4" "table_row" And I should see "Likely" in the ".alert-warning" "css_element" And I should see "Unlikely" in the ".alert-success" "css_element" Scenario: View discrimination index in question bank, bad questions Given user "student1" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | True | And user "student2" has attempted "Quiz 2" with responses: | slot | response | | 1 | False | | 2 | True | And user "student3" has attempted "Quiz 2" with responses: | slot | response | | 1 | True | | 2 | False | When I am on the "Course 1" "core_question > course question bank" page logged in as "admin" Then I should see "Likely" in the "TF1" "table_row" And I should see "Very likely" in the "TF2" "table_row" And I should see "Very likely" in the "TF3" "table_row" And I should see "Likely" in the "TF4" "table_row" And I should see "Very likely" in the ".alert-danger" "css_element" And I should see "Likely" in the ".alert-warning" "css_element" templates/discrimination_index.mustache 0000644 00000001773 15152217600 0014507 0 ustar 00 {{! 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/>. }} {{! @template qbank_statistics/discrimination_index Example context (json): { "statistics": [ { "discriminative_efficiency": "html content" } ] } }} <div class="qbank_statistics discrimination_index {{classes}}"> {{{discrimination_index}}} </div> templates/discriminative_efficiency.mustache 0000644 00000001776 15152217600 0015505 0 ustar 00 {{! 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/>. }} {{! @template qbank_statistics/discriminative_efficiency Example context (json): { "statistics": [ { "discriminative_efficiency": "html content" } ] } }} <div class="qbank_statistics discriminative_efficiency"> {{{discriminative_efficiency}}} </div> templates/facility_index.mustache 0000644 00000001722 15152217600 0013271 0 ustar 00 {{! 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/>. }} {{! @template qbank_statistics/facility_index Example context (json): { "statistics": [ { "facility_index": "html content" } ] } }} <div class="qbank_statistics facility_index"> {{{facility_index}}} </div> version.php 0000644 00000002142 15152217600 0006740 0 ustar 00 <?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/>. /** * Question statistics * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_statistics'; $plugin->version = 2022112800; $plugin->requires = 2022111800; $plugin->maturity = MATURITY_STABLE; classes/output/renderer.php 0000644 00000004633 15152217600 0012065 0 ustar 00 <?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 qbank_statistics\output; use qbank_statistics\helper; /** * Description * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class renderer extends \plugin_renderer_base { /** * Render facility index column. * * @param float|null $facility facility index * @return string */ public function render_facility_index(?float $facility): string { $displaydata['facility_index'] = helper::format_percentage($facility); return $this->render_from_template('qbank_statistics/facility_index', $displaydata); } /** * Render discriminative_efficiency column. * * @param float|null $discriminativeefficiency discriminative efficiency * @return string */ public function render_discriminative_efficiency(?float $discriminativeefficiency): string { $displaydata['discriminative_efficiency'] = helper::format_percentage($discriminativeefficiency, false); return $this->render_from_template('qbank_statistics/discriminative_efficiency', $displaydata); } /** * Render discrimination index column. * * @param float|null $discriminationindex discrimination index * @return string */ public function render_discrimination_index(?float $discriminationindex): string { list($content, $classes) = helper::format_discrimination_index($discriminationindex); $displaydata['discrimination_index'] = $content; $displaydata['classes'] = $classes; return $this->render_from_template('qbank_statistics/discrimination_index', $displaydata); } } classes/privacy/provider.php 0000644 00000002406 15152217600 0012222 0 ustar 00 <?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 qbank_statistics\privacy; /** * Privacy Subsystem implementation. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Explain why qbank_statistics store no data * * @return string reason why this plugin store no data */ public static function get_reason(): string { return 'privacy:metadata'; } } classes/helper.php 0000644 00000021737 15152217600 0010202 0 ustar 00 <?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 qbank_statistics; use core_question\statistics\questions\all_calculated_for_qubaid_condition; use core_component; /** * Helper for statistics * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class helper { /** * @var float Threshold to determine 'Needs checking?' */ private const NEED_FOR_REVISION_LOWER_THRESHOLD = 30; /** * @var float Threshold to determine 'Needs checking?' */ private const NEED_FOR_REVISION_UPPER_THRESHOLD = 50; /** * For a list of questions find all the places (defined by (component, contextid) where there are attempts. * * @param int[] $questionids array of question ids that we are interested in. * @return \stdClass[] list of objects with fields ->component and ->contextid. */ private static function get_all_places_where_questions_were_attempted(array $questionids): array { global $DB; [$questionidcondition, $params] = $DB->get_in_or_equal($questionids); // The MIN(qu.id) is just to ensure that the rows have a unique key. $places = $DB->get_records_sql(" SELECT MIN(qu.id) AS somethingunique, qu.component, qu.contextid, " . \context_helper::get_preload_record_columns_sql('ctx') . " FROM {question_usages} qu JOIN {question_attempts} qa ON qa.questionusageid = qu.id JOIN {context} ctx ON ctx.id = qu.contextid WHERE qa.questionid $questionidcondition GROUP BY qu.component, qu.contextid, " . implode(', ', array_keys(\context_helper::get_preload_record_columns('ctx'))) . " ORDER BY qu.contextid ASC ", $params); // Strip out the unwanted ids. $places = array_values($places); foreach ($places as $place) { unset($place->somethingunique); \context_helper::preload_from_record($place); } return $places; } /** * Load the question statistics for all the attempts belonging to a particular component in a particular context. * * @param string $component frankenstyle component name, e.g. 'mod_quiz'. * @param \context $context the context to load the statistics for. * @return all_calculated_for_qubaid_condition|null question statistics. */ private static function load_statistics_for_place(string $component, \context $context): ?all_calculated_for_qubaid_condition { // This check is basically if (component_exists). if (empty(core_component::get_component_directory($component))) { return null; } if (!component_callback_exists($component, 'calculate_question_stats')) { return null; } return component_callback($component, 'calculate_question_stats', [$context]); } /** * Extract the value for one question and one type of statistic from a set of statistics. * * @param all_calculated_for_qubaid_condition $statistics the batch of statistics. * @param int $questionid a question id. * @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'. * @return float|null the required value. */ private static function extract_item_value(all_calculated_for_qubaid_condition $statistics, int $questionid, string $item): ?float { // Look in main questions. foreach ($statistics->questionstats as $stats) { if ($stats->questionid == $questionid && isset($stats->$item)) { return $stats->$item; } } // If not found, look in sub questions. foreach ($statistics->subquestionstats as $stats) { if ($stats->questionid == $questionid && isset($stats->$item)) { return $stats->$item; } } return null; } /** * Calculate average for a stats item on a list of questions. * * @param int[] $questionids list of ids of the questions we are interested in. * @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'. * @return array array keys are question ids and the corresponding values are the average values. * Only questions for which there are data are included. */ private static function calculate_average_question_stats_item(array $questionids, string $item): array { $places = self::get_all_places_where_questions_were_attempted($questionids); $counts = []; $sums = []; foreach ($places as $place) { $statistics = self::load_statistics_for_place($place->component, \context::instance_by_id($place->contextid)); if ($statistics === null) { continue; } foreach ($questionids as $questionid) { $value = self::extract_item_value($statistics, $questionid, $item); if ($value === null) { continue; } $counts[$questionid] = ($counts[$questionid] ?? 0) + 1; $sums[$questionid] = ($sums[$questionid] ?? 0) + $value; } } // Return null if there is no quizzes. $averages = []; foreach ($sums as $questionid => $sum) { $averages[$questionid] = $sum / $counts[$questionid]; } return $averages; } /** * Calculate average facility index * * @param int $questionid * @return float|null */ public static function calculate_average_question_facility(int $questionid): ?float { $averages = self::calculate_average_question_stats_item([$questionid], 'facility'); return $averages[$questionid] ?? null; } /** * Calculate average discriminative efficiency * * @param int $questionid question id * @return float|null */ public static function calculate_average_question_discriminative_efficiency(int $questionid): ?float { $averages = self::calculate_average_question_stats_item([$questionid], 'discriminativeefficiency'); return $averages[$questionid] ?? null; } /** * Calculate average discriminative efficiency * * @param int $questionid question id * @return float|null */ public static function calculate_average_question_discrimination_index(int $questionid): ?float { $averages = self::calculate_average_question_stats_item([$questionid], 'discriminationindex'); return $averages[$questionid] ?? null; } /** * Format a number to a localised percentage with specified decimal points. * * @param float|null $number The number being formatted * @param bool $fraction An indicator for whether the number is a fraction or is already multiplied by 100 * @param int $decimals Sets the number of decimal points * @return string * @throws \coding_exception */ public static function format_percentage(?float $number, bool $fraction = true, int $decimals = 2): string { if (is_null($number)) { return get_string('na', 'qbank_statistics'); } $coefficient = $fraction ? 100 : 1; return get_string('percents', 'moodle', format_float($number * $coefficient, $decimals)); } /** * Format discrimination index (Needs checking?). * * @param float|null $value stats value * @return array */ public static function format_discrimination_index(?float $value): array { if (is_null($value)) { $content = get_string('emptyvalue', 'qbank_statistics'); $classes = ''; } else if ($value < self::NEED_FOR_REVISION_LOWER_THRESHOLD) { $content = get_string('verylikely', 'qbank_statistics'); $classes = 'alert-danger'; } else if ($value < self::NEED_FOR_REVISION_UPPER_THRESHOLD) { $content = get_string('likely', 'qbank_statistics'); $classes = 'alert-warning'; } else { $content = get_string('unlikely', 'qbank_statistics'); $classes = 'alert-success'; } return [$content, $classes]; } } classes/plugin_feature.php 0000644 00000003155 15152217600 0011726 0 ustar 00 <?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 qbank_statistics; use qbank_statistics\columns\facility_index; use qbank_statistics\columns\discrimination_index; use qbank_statistics\columns\discriminative_efficiency; /** * Class plugin_features is the entrypoint for the columns. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class plugin_feature extends \core_question\local\bank\plugin_features_base { /** * This method will return the array of objects to be rendered as a prt of question bank columns/actions. * * @param view $qbank * @return array */ public function get_question_columns($qbank): array { return [ new discrimination_index($qbank), new facility_index($qbank), new discriminative_efficiency($qbank) ]; } } classes/columns/facility_index.php 0000644 00000004322 15152217600 0013365 0 ustar 00 <?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 qbank_statistics\columns; use core_question\local\bank\column_base; use qbank_statistics\helper; /** * This column show the average facility index for this question. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class facility_index extends column_base { /** * Title for this column. * * @return string column title */ public function get_title(): string { return get_string('facility_index', 'qbank_statistics'); } public function help_icon(): ?\help_icon { return new \help_icon('facility_index', 'qbank_statistics'); } /** * Column name. * * @return string column name */ public function get_name(): string { return 'facility_index'; } /** * Output the contents of this column. * @param object $question the row from the $question table, augmented with extra information. * @param string $rowclasses CSS class names that should be applied to this row of output. */ protected function display_content($question, $rowclasses) { global $PAGE; // Average facility index per quiz. $facility = helper::calculate_average_question_facility($question->id); echo $PAGE->get_renderer('qbank_statistics')->render_facility_index($facility); } public function get_extra_classes(): array { return ['pr-3']; } } classes/columns/discriminative_efficiency.php 0000644 00000004517 15152217600 0015576 0 ustar 00 <?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 qbank_statistics\columns; use core_question\local\bank\column_base; use qbank_statistics\helper; /** * This column show the average discriminative efficiency for this question. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class discriminative_efficiency extends column_base { /** * Title for this column. * * @return string column title */ public function get_title(): string { return get_string('discriminative_efficiency', 'qbank_statistics'); } public function help_icon(): ?\help_icon { return new \help_icon('discriminative_efficiency', 'qbank_statistics'); } /** * Column name. * * @return string column name */ public function get_name(): string { return 'discriminative_efficiency'; } /** * Output the contents of this column. * @param object $question the row from the $question table, augmented with extra information. * @param string $rowclasses CSS class names that should be applied to this row of output. */ protected function display_content($question, $rowclasses) { global $PAGE; // Average discriminative efficiency per quiz. $discriminativeefficiency = helper::calculate_average_question_discriminative_efficiency($question->id); echo $PAGE->get_renderer('qbank_statistics')->render_discriminative_efficiency($discriminativeefficiency); } public function get_extra_classes(): array { return ['pr-3']; } } classes/columns/discrimination_index.php 0000644 00000004544 15152217600 0014603 0 ustar 00 <?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 qbank_statistics\columns; use core_question\local\bank\column_base; use qbank_statistics\helper; /** * This columns shows a message about whether this question is OK or needs revision. * * This is based on the average discrimination index. * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class discrimination_index extends column_base { /** * Title for this column. * * @return string column title */ public function get_title(): string { return get_string('discrimination_index', 'qbank_statistics'); } public function help_icon(): ?\help_icon { return new \help_icon('discrimination_index', 'qbank_statistics'); } /** * Column name. * * @return string column name */ public function get_name(): string { return 'discrimination_index'; } /** * Output the contents of this column. * @param object $question the row from the $question table, augmented with extra information. * @param string $rowclasses CSS class names that should be applied to this row of output. */ protected function display_content($question, $rowclasses) { global $PAGE; // Average discrimination index per quiz. $discriminationindex = helper::calculate_average_question_discrimination_index($question->id); echo $PAGE->get_renderer('qbank_statistics')->render_discrimination_index($discriminationindex); } public function get_extra_classes(): array { return ['pr-3']; } } lang/en/qbank_statistics.php 0000644 00000005141 15152217600 0012146 0 ustar 00 <?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/>. /** * Strings for component qbank_statistics, language 'en' * * @package qbank_statistics * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Nathan Nguyen <nathannguyen@catalyst-au.net> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $string['pluginname'] = 'Question statistics'; $string['privacy:metadata'] = 'The Question statistics question bank plugin does not store any personal data.'; // Columns. $string['facility_index'] = 'Facility index'; $string['facility_index_help'] = 'The facility index gives the average mark (as a percentage) obtained on the question (all versions) in all quizzes where the question has been attempted. A higher value normally indicates an easier question.'; $string['discriminative_efficiency'] = 'Discriminative efficiency'; $string['discriminative_efficiency_help'] = 'Discriminative efficiency is a statistical estimate of how well the question assesses students, with a higher value being better. A particularly low value may indicate a problem with the question. A very difficult or easy question (with facility index close to 0% or 100%) can also lead to a low value.'; $string['discriminative_efficiency_link'] = 'mod/quiz/statistics'; $string['discrimination_index'] = 'Needs checking?'; $string['discrimination_index_help'] = 'A question is indicated as likely to need checking based on question statistics. For example, if students obtain a low score on the question but a high score on the whole quiz, or a high score on the question but a low score on the whole quiz, then there may be a problem with the question such as the wrong answer being set as correct. Statistics are not infallible though; this is just a hint that the question should be checked.'; // Text format. $string['verylikely'] = 'Very likely'; $string['likely'] = 'Likely'; $string['unlikely'] = 'Unlikely'; $string['na'] = 'N/A'; $string['emptyvalue'] = '-';
| ver. 1.4 |
Github
|
.
| PHP 7.4.33 | ���֧ߧ֧�ѧ�ڧ� ����ѧߧڧ��: 0 |
proxy
|
phpinfo
|
���ѧ����ۧܧ�