. /** * This class performs the simple algorithm to determine the next level of difficulty a student should attempt. * It also recommends whether the calculation has reached an acceptable level of error. * * @copyright 2013 onwards Remote-Learner {@link http://www.remote-learner.ca/} * @copyright 2022 onwards Vitaly Potenko * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_adaptivequiz\local; use coding_exception; use dml_missing_record_exception; use moodle_exception; use question_state_gradedpartial; use question_state_gradedright; use question_state_gradedwrong; use question_state_todo; use question_usage_by_activity; use stdClass; class catalgo { /** @var $quba a question_usage_by_activity object */ protected $quba = null; /** @var $attemptid an adaptivequiz_attempt attempt id */ protected $attemptid = 0; /** * @var bool $debugenabled flag to denote developer debugging is enabled and this class should write message to the debug array */ protected $debugenabled = false; /** @var array $debug debugging array of messages */ protected $debug = array(); /** @var int $level level of difficulty of the most recently attempted question */ protected $level = 0; /** * @var float $levelogit the logit value of the difficulty level represented as a percentage of the minimum and maximum * difficulty @see compute_next_difficulty() */ protected $levellogit = 0.0; /** @var bool $readytostop flag to denote whether to assume the student has met the minimum requirements */ protected $readytostop = true; /** @var int $questattempted the sum number of questions attempted */ protected $questattempted = 0; /** @var $difficultysum the sum of the difficulty levels attempted */ protected $difficultysum = 0; /** @var int $nextdifficulty the next dificulty level to administer */ protected $nextdifficulty = 0; /** @var int $sumofcorrectanswers the sum of questions answered correctly */ protected $sumofcorrectanswers; /** @var int @sumofincorrectanswers the sum of questions answered incorretly */ protected $sumofincorrectanswers; /** @var float $measure the ability measure */ protected $measure = 0.0; /** @var float $standarderror the standard error of the measure */ protected $standarderror = 0.0; /** @var string $status status message storing the reason why the attempt needs to be stopped */ protected $status = ''; /** * Constructor to initialize the parameters needed by the adaptive alrogithm * @throws moodle_exception - exception is thrown if first argument is not an instance of question_usage_by_activity class or * second argument is not a positive integer. * @param question_usage_by_activity $quba an object loaded using the unique id of the attempt * @param int $attemptid the adaptivequiz_attempt attempt id * @param bool $readytostop true of the algo should assume the user has answered the minimum number of question and should * compare the results againts the standard error * @param int $level the level of difficulty for the most recently attempted question * @return void */ public function __construct($quba, $attemptid, $readytostop = true, $level = 0) { if (!$quba instanceof question_usage_by_activity) { throw new coding_exception('catalgo: Argument 1 is not a question_usage_by_activity object', 'Question usage by activity must be a question_usage_by_activity object'); } if (!is_int($attemptid) || 0 >= $attemptid) { throw new coding_exception('catalgo: Argument 2 not a positive integer', 'Attempt id argument must be a positive integer'); } if (!is_int($level) || 0 >= $level) { throw new coding_exception('catalgo: Argument 4 not a positive integer', 'level must be a positive integer'); } $this->quba = $quba; $this->attemptid = $attemptid; $this->readytostop = $readytostop; $this->level = $level; if (debugging('', DEBUG_DEVELOPER)) { $this->debugenabled = true; } } /** * This function adds a message to the debugging array * @param string $message details of the debugging message * @return void */ protected function print_debug($message = '') { if ($this->debugenabled) { $this->debug[] = $message; } } /** * Answer a string view of a variable for debugging purposes * @param mixed $variable */ protected function vardump($variable) { ob_start(); var_dump($variable); return ob_get_clean(); } /** * This function returns the debug array * @return array array of debugging messages */ public function get_debug() { return $this->debug; } /** * This function returns the $difficultysum property * @return int returns the $difficultysum property */ public function get_difficultysum() { return $this->difficultysum; } /** * This function returns the $levellogit property * @return float retuns the $levellogit property */ public function get_levellogit() { return $this->levellogit; } /** * This function returns the $standarderror property * @return float retuns the $standarderror property */ public function get_standarderror() { return $this->standarderror; } /** * This function returns the $measure property * @return float retuns the $measure property */ public function get_measure() { return $this->measure; } /** * This functions retrieves the attempt record, the highest and lowest difficulty level set for the attempt * @throws dml_missing_record_exception * @param int $attemptid the attempt id record * @return stdClass adaptivequiz_attempt record */ public function retrieve_attempt_record($attemptid) { global $DB; $param = array('id' => $attemptid); $sql = "SELECT aa.id, aa.questionsattempted, aa.difficultysum, aa.standarderror, a.highestlevel, a.lowestlevel, a.acceptancethreshold, aa.measure FROM {adaptivequiz_attempt} aa JOIN {adaptivequiz} a ON a.id = aa.instance WHERE aa.id = :id ORDER BY id DESC"; $record = $DB->get_record_sql($sql, $param, MUST_EXIST); return $record; } /** * Refactored code from adaptiveattempt.class.php @see find_last_quest_used_by_attempt() * This function retrieves the last question that was used in the attempt * @return int question slot or 0 if no unmarked question could be found */ protected function find_last_quest_used_by_attempt() { if (!$this->quba instanceof question_usage_by_activity) { $this->print_debug('find_last_quest_used_by_attempt() - Argument was not a question_usage_by_activity object'); return 0; } // The last slot in the array should be the last question that was attempted (meaning it was either shown to the user or the // user submitted an answer to it). $questslots = $this->quba->get_slots(); if (empty($questslots) || !is_array($questslots)) { $this->print_debug('find_last_quest_used_by_attempt() - No question slots found for this question_usage_by_activity '. 'object'); return 0; } $questslot = end($questslots); $this->print_debug('find_last_quest_used_by_attempt() - Found a question slot: '.$questslot); return $questslot; } /** * Refactored code from adaptiveattempt.class.php @see was_answer_submitted_to_question() * This function determines if the user submitted an answer to the question * @param int $slot question slot id * @return bool true if an answer to the question was submitted, otherwise false */ protected function was_answer_submitted_to_question($slotid) { if (empty($slotid)) { $this->print_debug('was_answer_submitted_to_question() refactored - slot id was zero'); return false; } $state = $this->quba->get_question_state($slotid); // Check if the state of the quesiton attempted was graded right, partially right or wrong. $marked = $state instanceof question_state_gradedright || $state instanceof question_state_gradedpartial || $state instanceof question_state_gradedwrong; if ($marked) { return true; } else { // Save some debugging information. $debugmsg = 'was_answer_submitted_to_question() refactored - question state is unrecognized state: '.get_class($state); $debugmsg .= ' questionslotid: '.$slotid.' quba id: '.$this->quba->get_id(); $this->print_debug($debugmsg); } return false; } /** * This function retrieves the mark received from the student's submission to the question * @param question_usage_by_activity $quba an object loaded using the unique id of the attempt * @param int $slotid the slot id of the question * @return float|null a float representing the user's mark. Or null if there was no mark */ public function get_question_mark($quba, $slotid) { $mark = $quba->get_question_mark($slotid); if (is_float($mark)) { return $mark; } $this->print_debug('get_question_mark() - Question mark was not a float slot id: '.$slotid); return null; } //KNIGHT (Ulrike Pado): For the purpose of comparison with the acceptance threshold (Depending on the type of question, sometimes mark and sometimes fraction are used by the system) /** * This function retrieves the point fraction received from the student's submission to the question * @param question_usage_by_activity $quba an object loaded using the unique id of the attempt * @param int $slotid the slot id of the question * @return float|null a float representing the user's point fraction. Or null if there was no point fraction */ public function get_question_fraction($quba, $slotid) { $fraction = $quba->get_question_fraction($slotid); if (is_float($fraction)) { return $fraction; } $this->print_debug('get_question_fraction() - Question fraction was not a float - slot id: '.$slotid); return null; } /** * This function retrieves the maximum mark that can be achieved by answering the question (in case a fraction is missing and has to be * inferred from mark and maximum mark). * @param question_usage_by_activity $quba an object loaded using the unique id of the attempt * @param int $slotid the slot id of the question * @return float|null a float representing the maximum mark. Or null if there was no maximum mark */ public function get_question_max_mark($quba, $slotid) { $maxmark = $quba->get_question_max_mark($slotid); if (is_float($maxmark)) { return $maxmark; } $this->print_debug('get_question_max_mark() - Maximum question mark was not a float - slot id: '.$slotid); return null; } /** * This function determins whether the user answered the question correctly or incorrectly. * Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings * This function determines the current slot id and then calls * question_was_marked_correct_by_id($slotid) * @param question_usage_by_activity $quba an object loaded using the unique id of the attempt * @param int $slotid the slot id of the question * @return bool|null a boolean representing question correctness or null if there is no valid slot */ public function question_was_marked_correct() { // Find the last question attempted by the user. $slotid = $this->find_last_quest_used_by_attempt(); // if there is no valid slot, end here if (empty($slotid)) { return null; } // increment the number of attempted questions $this->questattempted++; $this->print_debug('question_was_marked_correct() - '.$this->questattempted.' questions attempted'); // pass on to parametrized version return $this->question_was_marked_correct_by_id($slotid); } /** * This function determines whether the question was answered correctly or incorrectly given a slot id. * Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings * @param int $slotid question slot id * @return boolean the correctness of the answer */ public function question_was_marked_correct_by_id($slotid) { // always needs checking in case the method is called directly // (not from question_was_marked_correct()) if (empty($slotid)) { return null; } // Check if the question was answered. If not, it counts as answered wrong if (!$this->was_answer_submitted_to_question($slotid)) { return false; } // Retrieve the fraction of points received. $fraction = $this->get_question_fraction($this->quba, $slotid); // get $mark and scale it by maximum mark if $fraction is null if (is_null($fraction)) { $this->print_debug('question_was_marked_correct_by_id - fraction is null. Getting mark.'); $mark = $this->get_question_mark($this->quba, $slotid); // fraction and mark are both null if (is_null($mark)) { return null; } //Also retrieve maximum mark for scaling of mark $maxmark = $this->get_question_max_mark($this->quba, $slotid); // compute the value that $fraction should have $fraction = $mark/$maxmark; } // $fraction is now set $this->print_debug('question_was_marked_correct_by_id - Fraction is '.$fraction); // Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings $record = $this->retrieve_attempt_record($this->attemptid); $this->acceptancethreshold = $record->acceptancethreshold; if ( $fraction >= $this->acceptancethreshold) { $this->print_debug('question_was_marked_correct_by_id - mark is indeed correct'); return true; } $this->print_debug('question_was_marked_correct_by_id - mark is indeed incorrect'); return false; } /** * This function retrieves the allowed standard error (as a percent) for the attempt * @throws dml_missing_record_exception * @param int $attemptid adaptivequiz_attempt id * @return float the standard error allowed */ public function retrieve_standard_error($attemptid) { global $DB; $param = array('aaid' => $attemptid); $sql = "SELECT a.standarderror FROM {adaptivequiz} a JOIN {adaptivequiz_attempt} aa ON a.id = aa.instance WHERE aa.id = :aaid ORDER BY a.standarderror ASC"; return (float) $DB->get_field_sql($sql, $param, MUST_EXIST); } /** * This function performs the different steps in the CAT simple algorithm * @return int returns the next difficulty level or 0 if there was an error */ public function perform_calculation_steps() { // Retrieve attempt record. $record = $this->retrieve_attempt_record($this->attemptid); $this->difficultysum = $record->difficultysum; $this->questattempted = $record->questionsattempted; // If the user answered the previous question correctly, calculate the sum of correct answers. $correct = $this->question_was_marked_correct(); if (true === $correct) { // Compute the next difficulty level for the next question. $this->nextdifficulty = $this->compute_next_difficulty($this->level, $this->questattempted, true, $record); } else if (false === $correct) { // Compute the next difficulty level for the next question. $this->nextdifficulty = $this->compute_next_difficulty($this->level, $this->questattempted, false, $record); } else { $this->status = get_string('errorlastattpquest', 'adaptivequiz'); $this->print_debug('perform_calculation_steps() - Last question attempted returned a null as an answer'); return 0; } // If he user hasn't met the minimum requirements to end the attempt, then return with the next difficulty level. if (empty($this->readytostop)) { $this->print_debug('perform_calculation_steps() - Not ready to stop the attempt, returning next difficulty number'); return $this->nextdifficulty; } // Calculate the sum of correct answers and the sum of incorrect answers. $this->sumofcorrectanswers = $this->compute_right_answers($this->quba); $this->sumofincorrectanswers = $this->compute_wrong_answers($this->quba); if (0 == $this->questattempted) { $this->status = get_string('errornumattpzero', 'adaptivequiz'); $this->print_debug('perform_calculation_steps() - number of questions attempted equals zero'); return 0; } // Test that the sum of incorrect and correct answers equal to the sum of question attempted. $validatenumbers = $this->sumofcorrectanswers + $this->sumofincorrectanswers; if ($validatenumbers != $this->questattempted) { $this->status = get_string('errorsumrightwrong', 'adaptivequiz'); $this->print_debug('perform_calculation_steps() - Sum of correct and incorrect answers ('.$validatenumbers.') '. 'doesn\'t equals the total number of questions attempted ('.$this->questattempted.')'); return 0; } // Get the measure estimate. $this->measure = self::estimate_measure($this->difficultysum, $this->questattempted, $this->sumofcorrectanswers, $this->sumofincorrectanswers); // Get the standard error estimate. $this->standarderror = self::estimate_standard_error($this->questattempted, $this->sumofcorrectanswers, $this->sumofincorrectanswers); $this->print_debug('perform_calculation_steps() - difficultysum: '.$this->difficultysum.', questattempted: '. $this->questattempted.', sumofcorrectanswers: '.$this->sumofcorrectanswers.', sumofincorrectanswers: '. $this->sumofincorrectanswers.' => measure: '.$this->measure.', standard error: '.$this->standarderror); // Retrieve the standard error (as a percent) set for the attempt, convert it into a decimal percent then // convert to a logit. $quizdefinederror = $this->retrieve_standard_error($this->attemptid); $quizdefinederror = $quizdefinederror / 100; $quizdefinederror = self::convert_percent_to_logit($quizdefinederror); // If the calculated standard error is within the parameters of the attempt then populate the status message. if ($this->standard_error_within_parameters($this->standarderror, $quizdefinederror)) { // Convert logits to percent for display. $val = new stdClass(); $val->calerror = self::convert_logit_to_percent($this->standarderror); $val->calerror = 100 * round($val->calerror, 2); $val->definederror = self::convert_logit_to_percent($quizdefinederror); $val->definederror = 100 * round($val->definederror, 2); $this->status = get_string('calcerrorwithinlimits', 'adaptivequiz', $val); } $this->print_debug('perform_calculation_steps() - measure: '.$this->measure.' standard error: '.$this->standarderror); return $this->nextdifficulty; } /** * This function returns the currently set status message * @return string the status message property */ public function get_status() { return $this->status; } /** * This function takes a percent as a float between 0 and less than 0.5 and converts it into a logit value * @throws coding_exception if percent is out of bounds * @param float $percent percent represented as a decimal 15% = 0.15 * @return float logit value of percent */ public static function convert_percent_to_logit($percent) { if ($percent < 0 || $percent >= 0.5) { throw new coding_exception('convert_percent_to_logit: percent is out of bounds', 'Percent must be 0 >= and < 0.5'); } return log( (0.5 + $percent) / (0.5 - $percent) ); } /** * This function takes a logit as a float greater than or equal to 0 and converts it into a percent * @throws coding_exception if logit is out of bounds * @param float $logit logit value * @return float logit value of percent */ public static function convert_logit_to_percent($logit) { if ($logit < 0) { throw new coding_exception('convert_logit_to_percent: logit is out of bounds', 'logit must be greater than or equal to 0'); } return ( 1 / ( 1 + exp(0 - $logit) ) ) - 0.5; } /** * Convert a logit value to a fraction between 0 and 1. * @param float $logit logit value * @return float the logit value mapped as a fraction */ public static function convert_logit_to_fraction($logit) { return exp($logit) / ( 1 + exp($logit) ); } /** * This function takes the inverse of a logit value, then maps the value onto the scale defined for the attempt * @param float $logit logit value * @param int $max the maximum value of the scale * @param int $min the minimum value of the scale * @return float the logit value mapped onto the scale */ public static function map_logit_to_scale($logit, $max, $min) { $fraction = self::convert_logit_to_fraction($logit); $scaledvalue = ( ( $max - $min ) * $fraction ) + $min; return $scaledvalue; } /** * This function compares the calulated standard error with the activity defined standard error allowd for the attempt * @param float $calculatederror the error calculated from the parameters of the user's current attempt * @param float $definederror the allowed error set for the activity instance * @return bool true if the calulated error is less than or equal to the defined error, otherwise false */ public function standard_error_within_parameters($calculatederror, $definederror) { if ($calculatederror <= $definederror) { return true; } else { return false; } } /** * This function estimates the standard error in the measurement * @param int $questattempt the number of question attempted * @param int $sumcorrect the sum of correct answers * @param int $sumincorrect the sum of incorrect answers * @return float a decimal rounded to 5 places is returned */ public static function estimate_standard_error($questattempt, $sumcorrect, $sumincorrect) { if ($sumincorrect == 0) { $standarderror = sqrt($questattempt / ( ($sumcorrect - 0.5) * ($sumincorrect + 0.5) ) ); } else if ($sumcorrect == 0) { $standarderror = sqrt($questattempt / ( ($sumcorrect + 0.5) * ($sumincorrect - 0.5) ) ); } else { $standarderror = sqrt($questattempt / ( $sumcorrect * $sumincorrect ) ); } return round($standarderror, 5); } /** * This function estimates the measure of ability * @param float $diffsum the sum of difficulty levels expressed as logits * @param int $questattempt the number of question attempted * @param int $sumcorrect the sum of correct answers * @param int $sumincorrect the sum of incorrect answers * @return float an estimate of the measure of ability */ public static function estimate_measure($diffsum, $questattempt, $sumcorrect, $sumincorrect) { if ($sumincorrect == 0) { $measure = ($diffsum / $questattempt) + log( ($sumcorrect - 0.5) / ($sumincorrect + 0.5) ); } else if ($sumcorrect == 0) { $measure = ($diffsum / $questattempt) + log( ($sumcorrect + 0.5) / ($sumincorrect - 0.5) ); } else { $measure = ($diffsum / $questattempt) + log( $sumcorrect / $sumincorrect ); } return round($measure, 5, PHP_ROUND_HALF_UP); } /** * This function counts the total number of correct answers for the attempt * KNIGHT (Ulrike Pado): Use a question's fraction, not mark. * Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings * @param question_usage_by_activity $quba an object loaded using the unique id of the attempt * @return int the number of correct answer submission */ public function compute_right_answers($quba) { $correctanswers = 0; // Get question slots for the attempt. $slots = $quba->get_slots(); // Iterate over slots and count correct answers. foreach ($slots as $slot) { if ($this->question_was_marked_correct_by_id($slot)) { $correctanswers++; } } $this->print_debug('compute_right_answers() - Sum of correct answers: '.$correctanswers); return $correctanswers; } /** * This function counts the total number of incorrect answers for the attempt * KNIGHT (Ulrike Pado): Use a question's fraction, not mark. * Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings * @param question_usage_by_activity $quba an object loaded using the unique id of the attempt * @return int the number of correct answer submission */ public function compute_wrong_answers($quba) { $incorrectanswers = 0; // Get question slots for the attempt. $slots = $quba->get_slots(); // Iterate over slots and count correct answers. foreach ($slots as $slot) { if (!$this->question_was_marked_correct_by_id($slot)) { $incorrectanswers++; } } $this->print_debug('compute_right_answers() - Sum of incorrect answers: '.$incorrectanswers); return $incorrectanswers; } /** * This function is a helper method to compute the current difficult level the attempt is at * @throws coding_exception if any of the parameters contain invalid data * @param question_usage_by_activity $quba a question usage by activity set to an attempt id * @param int $startinglevel the starting level of difficulty for the attempt * @param stdClass $attemptobj an object with the following properties: lowestlevel and highestlevel * @return int the current level of difficulty */ public function get_current_diff_level($quba, $level, $attemptobj) { // Check if level is a positive integer. if (!is_int($level) || 0 >= $level) { throw new coding_exception('get_current_diff_level: Arg 2 needs to be a positive integer', 'Invalid level of :'.$level.' was passed'); } // Check if quba is a valid instance of question_usage_by_activity. if (!$quba instanceof question_usage_by_activity) { throw new coding_exception('get_current_diff_level: Arg 1 needs to be an instance of question_usage_by_activity', 'Invalid quba of :'.get_class($quba)); } // Check if attempt object has required properties defined. if (!isset($attemptobj->lowestlevel) || !isset($attemptobj->highestlevel)) { throw new coding_exception('get_current_diff_level: Arg 3 needs to have lowestlevel and highestlevel properties', 'Invalid attemptobj of :'.$this->vardump($attemptobj)); } // Check if attempt object has required property value types. $conditions = !is_int($attemptobj->lowestlevel) || 0 >= $attemptobj->lowestlevel || !is_int($attemptobj->highestlevel) || 0 >= $attemptobj->highestlevel || $attemptobj->lowestlevel >= $attemptobj->highestlevel; if ($conditions) { throw new coding_exception('get_current_diff_level: Arg 3 lowestlevel and highestlevel properties must be positive '. 'integers', 'Invalid attemptobj of :'.$this->vardump($attemptobj)); } return $this->return_current_diff_level($quba, $level, $attemptobj); } /** * This function calculates the currently difficulty level of the attempt. * @param question_usage_by_activity $quba a question usage by activity set to an attempt id * KNIGHT (Ulrike Pado): Use a question's fraction, not mark. * Questions are considered correct if the marked fraction is at least at the acceptance threshold determined by the plugin settings * @param int $level the starting level of difficulty for the attempt * @param stdClass $attemptobj an object with the following properties: lowestlevel and highestlevel * @return int the current level of difficulty */ protected function return_current_diff_level($quba, $level, $attemptobj) { $questattempted = 0; $correct = false; // Set current difficulty to the starting level. $currdiff = $level; // Get question slots for the attempt. $slots = $quba->get_slots(); if (empty($slots)) { return 0; } // Get the last question's state. $state = $quba->get_question_state(end($slots)); // If the state of the last question in the attempt is 'todo' remove it from the array, as the user never submitted their // answer. if ($state instanceof question_state_todo) { array_pop($slots); } // Reset the array pointer back to the beginning. reset($slots); // Iterate over slots and count correct answers. foreach ($slots as $slot) { $corr = $this->question_was_marked_correct_by_id($slot); if (is_null($corr)||!$corr) { $correct = false; } else { $correct = true; } $questattempted++; $currdiff = $this->compute_next_difficulty($currdiff, $questattempted, $correct, $attemptobj); } return $currdiff; } /** * This function does the work to determine the next difficulty level * @param int $level the difficulty level of the last question attempted * @param int $questattempted the sum of questions attempted * @param bool $correct true of the user got the previous question correct, otherwise false * @param stdClass $attempt a data record returned from @see retrieve_attempt_record() * @return int the next difficult level */ public function compute_next_difficulty($level, $questattempted, $correct, $attempt) { $nextdifficulty = 0; // Map the linear scale to a logrithmic logit scale. $ls = self::convert_linear_to_logit($level, $attempt->lowestlevel, $attempt->highestlevel); // Set the logit value of the previously attempted question's difficulty level. $this->levellogit = $ls; $this->difficultysum = $this->difficultysum + $this->levellogit; // Check if the last question was marked correctly. if ($correct) { $nextdifficulty = $ls + 2 / $questattempted; } else { $nextdifficulty = $ls - 2 / $questattempted; } // Calculate the inverse to translate the value into a difficulty level. $invps = 1 / ( 1 + exp( (-1 * $nextdifficulty) ) ); $invps = round($invps, 2); $difflevel = $attempt->lowestlevel + ( $invps * ($attempt->highestlevel - $attempt->lowestlevel) ); $difflevel = round($difflevel); $this->print_debug('compute_next_difficulty() - Next difficulty level is: '.$difflevel); return (int) $difflevel; } /** * Map an linear-scale difficulty/ability level to a logit scale * * @param int $level An integer level * @param int $min The lower bound of the scale * @param int $max The upper bound of the scale * @return float */ public static function convert_linear_to_logit($level, $min, $max) { // Map the level on a linear percentage scale. $percent = ($level - $min) / ($max - $min); // We will use a limit that is 1/2th the granularity of the question levels as our base. // For example, for levels 1-100, we will use a base of 0.5% (5.3 logits), // for levels 1-1000 we will use a base of 0.05% (7.6 logits). // // Note that the choice of 1/2 the granularity is somewhat arbitrary. // The floor value for the ends of the scale is being chosen so that answers // at the end of the scale do not excessively weight the ability measure // in ways that are not recoverable by subsequent answers. // // For example, lets say that on a scale of 1-10, a user of level 5 makes // a dumb mistake and answers two level 1 questions wrong, but then continues // the test and answers 20 more questions with every question up to level 5 // right and those above wrong. The test should likely score the user somewhere // a bit below 5 with 5 being included in the Standard Error. // // Several test runs with different floors showed that 1/1000 gave far too // much weight to answers at the edge of the scale. 1/10 did ok, but // 1/2 seemed to allow recovery from spurrious answers at the edges while // still allowing consistent answers at the edges to trend the ability measure to // the top/bottom level. $granularity = 1 / ($max - $min); $percentfloor = $granularity / 2; // Avoid a division by zero error. if ($percent == 1) { $percent = 1 - $percentfloor; } // Map the percentage scale to a logrithmic logit scale. $logit = log( $percent / (1 - $percent) ); // Check if result is inifinite. if (is_infinite($logit)) { $logitfloor = log( $percentfloor / (1 - $percentfloor) ); if ($logit > 0) { return -1 * $logitfloor; } else { return $logitfloor; } } return $logit; } }