Location: PHPKode > projects > TestMaker > testmaker-3.3p4/core/types/AdaptiveTestSession.php
<?php

/* This file is part of testMaker.

testMaker is free software; you can redistribute it and/or modify
it under the terms of version 2 of the GNU General Public License as
published by the Free Software Foundation.

testMaker 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 this program.  If not, see <http://www.gnu.org/licenses/>. */


/**
 * @package Core
 */

libLoad("utilities::ExternalStateObject");

/**
 * Contains the logic needed to run adaptive tests
 *
 * Ported from the original testMaker.
 *
 * @package Core
 */
class AdaptiveTestSession extends ExternalStateObject
{
	/**
	 * Whether to randomize all items (<kbd>TRUE</kbd>) or only the first (<kbd>FALSE</kbd>)
	 * Adjust if necessary before calling {@link init()}
	 * @access public
	 */
	var $randomizeAll = TRUE;

	/**
	 * How many of the best items should be taken into account when randomizing
	 * Adjust if necessary before calling {@link init()}
	 * @access public
	 */
	var $randomizerLimit = 5;

	/**
	 * How many quadrature points should be used
	 * Adjust if necessary before calling {@link init()}
	 * @access public
	 */
	var $quadraturePoints = 41;

	/**
	 * Minimum Theta value
	 * Adjust if necessary before calling {@link init()}
	 * @access public
	 */
	var $thetaMin = -4.2;

	/**
	 * Theta width
	 * Adjust if necessary before calling {@link init()}
	 * @access public
	 */
	var $thetaWidth = 8.4;

	/**
	 * The minimum information limit per step
	 * Adjust if necessary before calling {@link init()}
	 * @access public
	 */
	var $minInfoLowerLimit = 0;

	/**
	 * How often the minimum information limit may be crossed before aborting
	 * Adjust if necessary before calling {@link init()}
	 * @access public
	 */
	var $maxMinInfoLimitCrossCount = 0;

	/**#@+
	 * @access private
	 */
	var $maxItemCount = 0;
	var $maxSem = 0.0;

	var $currentItemId = NULL;
	var $remainingItems = array();
	var $processedItems = array();

	var $stepNumber = 0;
	var $minInfoLimitCrossCount = 0;
	var $theta = 0;
	var $sem = 99.9;

	var $quadraturePointArray;
	/**#@-*/

	/**
	 * Initializes an adaptive test session
	 * Constructor replacement, see {@link ExternalStateObject}
	 * @param ItemBlock Adaptive item block
	 */
	function init($itemBlock)
	{
		$this->maxItemCount = $itemBlock->getMaxItems();
		if ($this->maxItemCount == 0)
			$this->maxItemCount = 1000000;
		$this->maxSem = $itemBlock->getMaxSem();

		$quadraturePointArray = new QuadraturePointArray();
		$quadraturePointArray->init($this->quadraturePoints, $this->thetaMin, $this->thetaWidth);
		$quadraturePointArray->saveState($this->quadraturePointArray);

		$adaptiveItem = new AdaptiveTestItem();
		foreach ($itemBlock->getTreeChildren() as $item) {
			$adaptiveItem->init($item->getId(), $item->getDiscrimination(), $item->getDifficulty(), $item->getGuessing());
			$adaptiveItem->saveState($this->remainingItems[$item->getId()]);
		}
	}

	/**
	 * Returns the theta value
	 * @return double
	 */
	function getTheta()
	{
		return $this->theta;
	}

	/**
	 * Returns the standard error of measurement
	 * @return double;
	 */
	function getSem()
	{
		return $this->sem;
	}

	/**
	 * Processes the answer to the current item
	 * Call {@link getCurrentItemId()} afterwards to get the ID of the new item
	 * @param boolean whether the current item was answered correctly (ignored for the first item)
	 * @param int The ID of the next item (should be NULL unless you are resuming a test run)
	 */
	function processAnswer($isCorrect, $nextItemId = NULL)
	{
		if ($this->stepNumber > 0)
		{
			$adaptiveItem = new AdaptiveTestItem();
			$adaptiveItem->loadState($this->remainingItems[$this->currentItemId]);
			$adaptiveItem->setAnswered($isCorrect);
			$adaptiveItem->saveState($this->processedItems[$this->currentItemId]);
			unset($this->remainingItems[$this->currentItemId]);
		}

		// Calculate L_n (Q_i) * W_i
		$productLW = array();
		$this->_calcProductLW($productLW);
		$this->_calcNewEstimate($productLW);
		$this->_calcSem($productLW);

		$this->_findNextItem($nextItemId);

		$this->stepNumber++;
	}

	/**
	 * Returns the ID of the current item
	 * @return int
	 */
	function getCurrentItemId()
	{
		return $this->currentItemId;
	}

	/**
	 * Checks whether the adaptive test session is considered finished
	 * @return boolean
	 */
	function isFinished()
	{
		
		$this->maxSem = $this->maxSem + 0.0;
		
		return $this->sem < $this->maxSem
			|| $this->stepNumber > $this->maxItemCount
			|| count($this->remainingItems) == 0
			|| $this->minInfoLimitCrossCount > $this->maxMinInfoLimitCrossCount;
	}


	/**
	 * @access private
	 */
	function _isLessMaxQuad($i){
		$quadraturePointArray = new QuadraturePointArray();
		$quadraturePointArray->loadState($this->quadraturePointArray);
		return $quadraturePointArray->isInArray($i);
	}

	/**
	 * @access private
	 */
	function _getQ($i){
		$quadraturePointArray = new QuadraturePointArray();
		$quadraturePointArray->loadState($this->quadraturePointArray);
		return $quadraturePointArray->getQ($i);
	}

	/**
	 * @access private
	 */
	function _getW($i){
		$quadraturePointArray = new QuadraturePointArray();
		$quadraturePointArray->loadState($this->quadraturePointArray);
		return $quadraturePointArray->getW($i);
	}

	/**
	 * @access private
	 */
	function _updateMinInfoLimitCrossCount($info)
	{
		if ($info > $this->minInfoLowerLimit) {
			$this->minInfoLimitCrossCount = 0;
		} else {
			$this->minInfoLimitCrossCount++;
		}
	}

	/**
	 * @access private
	 */
	function _findNextItem($nextItemId = NULL)
	{
		$adaptiveItem = new AdaptiveTestItem();

		if ($this->isFinished()) {
			$this->currentItemId = NULL;
			return;
		}

		if (isset($nextItemId))
		{
			$this->currentItemId = $nextItemId;

			$adaptiveItem->loadState($this->remainingItems[$this->currentItemId]);
			$this->_updateMinInfoLimitCrossCount($adaptiveItem->calcItemInformation($this->theta));

			return;
		}

		// Calculate item information for each item
		$itemInformation = array();
		foreach($this->remainingItems as $itemState) {
			$adaptiveItem->loadState($itemState);
			$itemInformation[$adaptiveItem->getId()] = $adaptiveItem->calcItemInformation($this->theta);
		}

		// Sort by item information, maintaining key-value association
		arsort($itemInformation);

		// Determine the index to use
		if ($this->randomizeAll || $this->stepNumber == 0) {
			$itemIndex = mt_rand(0, min($this->randomizerLimit, count($itemInformation))-1);
		} else {
			$itemIndex = 0;
		}

		// Reset and advance the pointer to the given index
		reset($itemInformation);
		for ($i = 0; $i < $itemIndex; $i++) {
			next($itemInformation);
		}

		// The array pointer is now set to the item to use
		$this->currentItemId = key($itemInformation);
		$this->_updateMinInfoLimitCrossCount(current($itemInformation));
	}


	/**
	 * @access private
	 */
	function _calcProductLW(&$productLW)
	{
		for ($i = 0; $this->_isLessMaxQuad($i); $i++) {
			$productLW[$i] = $this->_getW($i) * $this->calcProbabilityForQ($this->_getQ($i));
		}
	}

	/**
	 * @access private
	 */
	function _calcNewEstimate($productLW)
	{
		$result = 0;

		// Calculate numerator
		for ($i = 0; $this->_isLessMaxQuad($i); $i++) {
			$result += $this->_getQ($i) * $productLW[$i];
		}

		// Calculate demoninator and divide
		$result /= $this->_calcSumProductLW($productLW);

		$this->theta = $result;
	}

	/**
	 * @access private
	 */
	function _calcSem($productLW)
	{
		$result = 0.0;

		// Calculate sum in numerator
		for ($i = 0; $this->_isLessMaxQuad($i); $i++) {
			$result += pow($this->_getQ($i) - $this->theta, 2) * $productLW[$i];
		}

		// Calculate sum in denominator
		$result /= $this->_calcSumProductLW($productLW);

		// Take the square root
		$result = sqrt($result);

		$this->sem = $result;
	}

	/**
	 * @access private
	 */
	function _calcSumProductLW($productLW)
	{
		$result = 0.0;

		for ($i = 0; $this->_isLessMaxQuad($i); $i++) {
			$result += $productLW[$i];
		}

		return $result;
	}

	/**
	 * Calculate the propability that this test run took place with theta = Q
	 * @param double QuadraturePoint value
	 */
	function calcProbabilityForQ($q)
	{
		$result = 1.0;

		// Calculate the product of propabilities
		foreach ($this->processedItems as $itemState)
		{
			$adaptiveItem = new AdaptiveTestItem();
			$adaptiveItem->loadState($itemState);

			$p = $adaptiveItem->calcProbabilityOfCorrectness($q);

			if ($adaptiveItem->isCorrect()) {
				$result *= $p;
			} else {
				$result *= (1 - $p);
			}
		}

		return $result;
	}

	/**
	 * Debug function
	 */
	function display()
	{

		echo "<p><b>Processed:</b>";
		$adaptiveItem = new AdaptiveTestItem();

		foreach($this->processedItems as $itemState){
			$adaptiveItem->loadState($itemState);
			$adaptiveItem->display();
		}
		echo "\n<br/><b>Values</b>: Theta=",round($this->theta, 3)," SEM=",round($this->sem, 3)," StepNumber=",$this->stepNumber," CurrentItem=",$this->getCurrentItemId(),"\n";
	}

	/**
	 * Debug function
	 */
	function displayInit()
	{
		echo "MaxSem=",$this->maxSem," MaxItems=",$this->maxItemCount," Theta=",$this->theta," SEM=",$this->sem," StepNumber=",$this->stepNumber," CurrentItem=",$this->getCurrentItemId(),"\n";
		$quadraturePointArray = new QuadraturePointArray();
		$quadraturePointArray->loadState($this->quadraturePointArray);
		$quadraturePointArray->display();
		$this->display();
	}
}

/**
 * Manages the so-called quadrature points of the EAP model
 * @package Core
 */
class QuadraturePointArray extends ExternalStateObject
{
	/**#@+
	 * @access private
	 */
	var $points = array();
	/**#@-*/

	/**
	 * Initializes a quadrature point array
	 * Constructor replacement, see {@link ExternalStateObject}
	 * @param int Number of quadrature points to use
	 * @param double Minimum theta value
	 * @param double Theta width
	 */
	function init($pointCount, $thetaMin, $thetaWidth)
	{
		$step = $thetaWidth / ((double) $pointCount-1);

		$quadraturePoint = new QuadraturePoint();
		$this->points = array();
		for ($i = 0; $i < $pointCount; $i++)
		{
			$q = $thetaMin + ((double) $i) * $step;
			$quadraturePoint->init($q, $this->_calcNormalDistributionDensity($q));
			$quadraturePoint->saveState($this->points[$i]);
		}

		$this->_normalize();
	}

	/**
	 * Fits the weights of the QuadraturePoints to the discrete distribution
	 * @access private
	 */
	function _normalize()
	{
		$totalWeight = 0;
		for ($i = 0; $this->isInArray($i); $i++) {
			$totalWeight += $this->getW($i);
		}

		$quadraturePoint = new QuadraturePoint();
		for ($i = 0; $i < count($this->points); $i++) {
			$quadraturePoint->loadState($this->points[$i]);
			$quadraturePoint->normalizeQuadraturePointWeight($totalWeight);
		}
	}

	/**
	 * Returns whether $i is a valid array index
	 * @param int The index to check
	 * @return boolean
	 */
	function isInArray($i) {
		return $i < count($this->points);
	}

	/**
	 * Calls and returns {@link QuadraturePoint::getW()} of a certain quadrature point
	 * @param int The index of the quadrature point in question
	 */
	function getW($i)
	{
		$quadraturePoint = new QuadraturePoint();
		$quadraturePoint->loadState($this->points[$i]);
		return $quadraturePoint->getW();
	}

	/**
	 * Calls and returns {@link QuadraturePoint::getQ()} of a certain quadrature point
	 * @param int The index of the quadrature point in question
	 */
	function getQ($i)
	{
		$quadraturePoint = new QuadraturePoint();
		$quadraturePoint->loadState($this->points[$i]);
		return $quadraturePoint->getQ();
	}

	/**
	 * @access private
	 */
	function _calcNormalDistributionDensity($x, $mu = 0, $sigma = 1)
	{
		$result = 1 / ($sigma * sqrt(2*3.14));
		$exponent = pow($x - $mu, 2) / (2 * pow($sigma, 2));
		$result *= exp(-$exponent);

		return $result;
	}

	/**
	 * Debug function
	 */
	function display()
	{
		echo "<hr/><b>QuadraturePointArray Test with ",count($this->points)," points</b><br/>";

		$sum = 0;
		$quadraturePoint = new QuadraturePoint();
		for ($i = 0; $this->isInArray($i); $i++) {
			echo "<br>",$i," ";
			$quadraturePoint->loadState($this->points[$i]);
			$quadraturePoint->display();
			$sum = $sum + $this->getW($i);
		}
		echo "<br/><b>Gesamtsumme</b>=",$sum,"</p>";
	}
}

/**
 * Models a quadrature point
 * @package Core
 */
class QuadraturePoint extends ExternalStateObject
{
	/**
	 * Theta
	 * @var double
	 * @access private
	 */
	var $q;

	/**
	 * W
	 * @var double
	 * @access private
	 */
	var $w;

	/**
	 * Initializes an quadrature point
	 * Constructor replacement, see {@link ExternalStateObject}
	 * @param double Theta of the quadrature point
	 * @param double W of the quadrature point
	 */
	function init($q, $w){
		$this->q = $q;
		$this->w = $w;
	}

	/**
	 * Returns the Theta value
	 * @return double
	 */
	function getQ()
	{
		return $this->q;
	}

	/**
	 * Returns the W value
	 * @return double
	 */
	function getW()
	{
		return $this->w;
	}


	/**
	 * Normalizes W with the given divisor. Necessary because of discrete distribution
	 * @param double Divisor
	 */
	function normalizeQuadraturePointWeight($divisor)
	{
		$this->w /= $divisor;
	}

	/**
	 * Debug function
	 */
	function display()
	{
		echo "Q ",$this->q," W ",$this->getW();
	}
}

/**
 * Class to represent an item of an adaptive test
 * @package Core
 */
class AdaptiveTestItem extends ExternalStateObject
{
	/**#@+
	 * @access private
	 */
	var $id;

	var $a;
	var $b;
	var $c;

	var $isCorrect = NULL;
	var $isAnswered = FALSE;
	/**#@-*/

	/**
	 * Initializes an adaptive test item
	 * Constructor replacement, see {@link ExternalStateObject}
	 * @param double Discrimination
	 * @param double Difficulty
	 * @param double Guessing
	 */
	function init($id, $discrimination, $difficulty, $guessing)
	{
		$this->id = $id;

		$this->a = $discrimination;
		$this->b = $difficulty;
		$this->c = $guessing;
	}

	/**
	 * Returns the item ID
	 */
	function getId() {
		return $this->id;
	}

	/**
	 * Calculates the information of an item given theta
	 * @param double Theta
	 */
	function calcItemInformation($theta)
	{
		$p = $this->calcProbabilityOfCorrectness($theta);

		$quotient = pow(1 - $this->c, 2);
		$factor1 = pow($this->a, 2);
		$factor2 = (1-$p) / $p;
		$factor3 = pow($p - $this->c, 2);
		$result = pow (1.7, 2) * $factor1 * $factor2 * $factor3 / $quotient;

		return $result;
	}

	/**
	 * Calculates the probability that, given theta, the answer is correct
	 * @param double Theta
	 */
	function calcProbabilityOfCorrectness($theta)
	{
		// 3-PL model
		// Many statisticians are more familiar with the normal ogive, and prefer to work in probits.
		// The normal ogive and the logistic ogive are similar, and a conversion of 1.7 approximately aligns them.
		$quotient = 1 + exp(-1.7 * $this->a * ($theta - $this->b));
		$result = $this->c + (1 - $this->c) / $quotient;
		return $result;
	}

	/**
	 * Makes an unanswered item an answered item
	 */
	function setAnswered($isCorrect)
	{
		$this->isAnswered = TRUE;
		$this->isCorrect = $isCorrect;
	}

	/**
	 * Whether the answer was correct
	 */
	function isCorrect()
	{
		return $this->isCorrect;
	}

	/**
	 * Debug function
	 */
	function display()
	{
		echo " ItemID=",$this->getId()," ";
		if ($this->isAnswered) {
			echo " ",$this->isCorrect ? "true" : "false";
		}
	}
}

?>
Return current item: TestMaker