Fix exam simulation score prediction
This commit is contained in:
@@ -4,33 +4,35 @@ namespace QuestionsApi.Services;
|
||||
|
||||
public static class ExamSimulationScoring
|
||||
{
|
||||
public const string ModelVersion = "provisional-1";
|
||||
private const double PriorMean = 1000.0;
|
||||
private const double PriorSd = 400.0;
|
||||
public const string ModelVersion = "provisional-2";
|
||||
private const double PassingAccuracy = 0.60;
|
||||
private const double ScoreIntercept = 50.0;
|
||||
private const double ScoreScale = 250.0;
|
||||
|
||||
public static ExamSimulationScore Calculate(
|
||||
IReadOnlyList<ExamSimulationItem> items,
|
||||
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
|
||||
IReadOnlyDictionary<string, Question> questions)
|
||||
{
|
||||
var ability = EstimateAbility(items, responses, questions);
|
||||
var predicted = Math.Clamp((int)Math.Round(227.0 + (ability - 1000.0) * 15.0 / 200.0), 1, 300);
|
||||
var information = 1.0 / (PriorSd * PriorSd);
|
||||
var correct = 0;
|
||||
var seen = 0;
|
||||
var weightedCorrect = 0.0;
|
||||
var weightSum = 0.0;
|
||||
var squaredWeightSum = 0.0;
|
||||
var categories = new Dictionary<string, ExamSimulationCategoryScore>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!questions.TryGetValue(item.QuestionExternalId, out var question))
|
||||
continue;
|
||||
questions.TryGetValue(item.QuestionExternalId, out var question);
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var isCorrect = response?.ChoiceId == question.CorrectChoiceId;
|
||||
var isCorrect = question is not null && response?.ChoiceId == question.CorrectChoiceId;
|
||||
if (isCorrect) correct++;
|
||||
if (item.WasSeen) seen++;
|
||||
var weight = item.WasSeen ? 0.5 : 1.0;
|
||||
var probability = Expected(ability, item.QuestionElo);
|
||||
information += weight * probability * (1.0 - probability) * Math.Pow(Math.Log(10.0) / 400.0, 2);
|
||||
var difficulty = ResolveDifficulty(item, question);
|
||||
var weight = DifficultyWeight(difficulty);
|
||||
weightSum += weight;
|
||||
squaredWeightSum += weight * weight;
|
||||
if (isCorrect) weightedCorrect += weight;
|
||||
if (!categories.TryGetValue(item.BlueprintCategory, out var category))
|
||||
{
|
||||
category = new ExamSimulationCategoryScore();
|
||||
@@ -40,51 +42,53 @@ public static class ExamSimulationScoring
|
||||
if (isCorrect) category.Correct++;
|
||||
}
|
||||
|
||||
var abilitySe = Math.Sqrt(1.0 / information);
|
||||
var scoreSe = abilitySe * 15.0 / 200.0;
|
||||
var combinedSe = Math.Sqrt(7.0 * 7.0 + 15.0 * 15.0 + scoreSe * scoreSe);
|
||||
var range = (int)Math.Ceiling(combinedSe);
|
||||
var passProbability = NormalCdf((predicted - 200.0) / combinedSe);
|
||||
var rawAccuracy = items.Count == 0 ? 0.0 : (double)correct / items.Count;
|
||||
var adjustedAccuracy = weightSum == 0.0 ? 0.0 : weightedCorrect / weightSum;
|
||||
var predicted = ToScore(adjustedAccuracy);
|
||||
var effectiveSampleSize = squaredWeightSum == 0.0 ? 0.0 : weightSum * weightSum / squaredWeightSum;
|
||||
var accuracySe = effectiveSampleSize == 0.0
|
||||
? 0.0
|
||||
: Math.Sqrt(adjustedAccuracy * (1.0 - adjustedAccuracy) / effectiveSampleSize);
|
||||
var accuracyMargin = 1.96 * accuracySe;
|
||||
var rangeLow = ToScore(Math.Clamp(adjustedAccuracy - accuracyMargin, 0.0, 1.0));
|
||||
var rangeHigh = ToScore(Math.Clamp(adjustedAccuracy + accuracyMargin, 0.0, 1.0));
|
||||
var passProbability = accuracySe == 0.0
|
||||
? adjustedAccuracy >= PassingAccuracy ? 1.0 : 0.0
|
||||
: NormalCdf((adjustedAccuracy - PassingAccuracy) / accuracySe);
|
||||
return new ExamSimulationScore
|
||||
{
|
||||
PredictedScore = predicted,
|
||||
RangeLow = Math.Clamp(predicted - range, 1, 300),
|
||||
RangeHigh = Math.Clamp(predicted + range, 1, 300),
|
||||
RangeLow = rangeLow,
|
||||
RangeHigh = rangeHigh,
|
||||
PassProbability = Math.Round(passProbability, 4),
|
||||
Correct = correct,
|
||||
Total = items.Count,
|
||||
SeenCount = seen,
|
||||
AbilityElo = Math.Round(ability, 1),
|
||||
RawAccuracy = Math.Round(rawAccuracy, 4),
|
||||
DifficultyAdjustedAccuracy = Math.Round(adjustedAccuracy, 4),
|
||||
AbilityElo = null,
|
||||
Categories = categories
|
||||
};
|
||||
}
|
||||
|
||||
private static double EstimateAbility(
|
||||
IReadOnlyList<ExamSimulationItem> items,
|
||||
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
|
||||
IReadOnlyDictionary<string, Question> questions)
|
||||
public static int ResolveDifficulty(ExamSimulationItem item, Question? question = null)
|
||||
{
|
||||
var low = 100.0;
|
||||
var high = 1900.0;
|
||||
for (var iteration = 0; iteration < 80; iteration++)
|
||||
{
|
||||
var ability = (low + high) / 2.0;
|
||||
var derivative = -(ability - PriorMean) / (PriorSd * PriorSd);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!questions.TryGetValue(item.QuestionExternalId, out var question)) continue;
|
||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||
var actual = response?.ChoiceId == question.CorrectChoiceId ? 1.0 : 0.0;
|
||||
var weight = item.WasSeen ? 0.5 : 1.0;
|
||||
derivative += weight * (actual - Expected(ability, item.QuestionElo)) * Math.Log(10.0) / 400.0;
|
||||
}
|
||||
if (derivative > 0) low = ability; else high = ability;
|
||||
}
|
||||
return (low + high) / 2.0;
|
||||
if (item.QuestionDifficulty is >= 1 and <= 5) return item.QuestionDifficulty;
|
||||
return question?.Difficulty is >= 1 and <= 5 ? question.Difficulty : 3;
|
||||
}
|
||||
|
||||
private static double Expected(double ability, int difficulty) =>
|
||||
1.0 / (1.0 + Math.Pow(10.0, (difficulty - ability) / 400.0));
|
||||
private static double DifficultyWeight(int difficulty) => difficulty switch
|
||||
{
|
||||
1 => 0.8,
|
||||
2 => 0.9,
|
||||
3 => 1.0,
|
||||
4 => 1.1,
|
||||
5 => 1.2,
|
||||
_ => 1.0
|
||||
};
|
||||
|
||||
private static int ToScore(double accuracy) =>
|
||||
Math.Clamp((int)Math.Round(ScoreIntercept + ScoreScale * accuracy), 1, 300);
|
||||
|
||||
private static double NormalCdf(double value) => 0.5 * (1.0 + Erf(value / Math.Sqrt(2.0)));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user