feat: improve dropped question support

This commit is contained in:
2026-09-20 00:41:26 -04:00
parent ba84c4d82a
commit cea03048b8
18 changed files with 302 additions and 86 deletions
+68 -56
View File
@@ -994,11 +994,21 @@
let dropped = cb-data.at("dropped-questions", default: ())
if dropped.len() > 0 [
#plural(dropped.len(), "Question", "Questions")
#dropped.map(str).join(", ")
#dropped.map(d => str(d.number)).join(", ")
#plural(dropped.len(), "is", "are")
marked dropped in the assessment record, so
#plural(dropped.len(), "it is", "they are")
absent from this table and from every statistic above.
#{
let credited = dropped.filter(d => d.at("full-credit", default: false))
if credited.len() > 0 [
#plural(credited.len(), "Question", "Questions")
#credited.map(d => str(d.number)).join(", ")
#plural(credited.len(), "was", "were") credited to every student, so
#plural(credited.len(), "it remains", "they remain") in the score
denominator and the means above match the grade of record.
]
}
]
}
]
@@ -1173,69 +1183,71 @@
#let predictions = cb-data.at("predictions", default: (:))
#if show-predictions and predictions.at("predicted", default: 0) > 0 [
= How your predictions did
#block(breakable: false)[
= How your predictions did
#let n = predictions.at("predicted", default: 0)
#let calibrated = predictions.at("calibrated", default: 0)
#let signed-error = predictions.at("mean-signed-error", default: none)
#let abs-error = predictions.at("mean-abs-error", default: none)
#let n = predictions.at("predicted", default: 0)
#let calibrated = predictions.at("calibrated", default: 0)
#let signed-error = predictions.at("mean-signed-error", default: none)
#let abs-error = predictions.at("mean-abs-error", default: none)
#explain[
#if calibrated == 0 [
None of these #n #plural(n, "expectation", "expectations") rests on prior
data, so they are predictions rather than calibrations. A prediction that
misses is a fact about the prediction: it does not flag the item, and it is
summarised here instead of appearing #n times in the tables above. Once
`coursebank calibrate` has written statistics back into the bank, a
subsequent miss means the cohort or the teaching moved, and it will be
flagged.
] else [
#calibrated of #n #plural(n, "expectation", "expectations") rests on a
prior calibration. Those are the ones whose misses are flagged on the item,
because a calibrated item that moves is telling you about this cohort. The
rest are predictions, and a miss corrects the prediction.
#explain[
#if calibrated == 0 [
None of these #n #plural(n, "expectation", "expectations") rests on prior
data, so they are predictions rather than calibrations. A prediction that
misses is a fact about the prediction: it does not flag the item, and it is
summarised here instead of appearing #n times in the tables above. Once
`coursebank calibrate` has written statistics back into the bank, a
subsequent miss means the cohort or the teaching moved, and it will be
flagged.
] else [
#calibrated of #n #plural(n, "expectation", "expectations") rests on a
prior calibration. Those are the ones whose misses are flagged on the item,
because a calibrated item that moves is telling you about this cohort. The
rest are predictions, and a miss corrects the prediction.
]
]
]
#grid(
columns: (1fr, 1fr, 1fr),
gutter: 9pt,
stat-card(
"difficulty bias",
if signed-error != none {
(if signed-error >= 0 { "+" } else { "" }) + str(calc.round(signed-error * 100)) + " pts"
} else { "n/a" },
note: if signed-error != none and signed-error > 0 {
"items came out easier than you expected"
} else if signed-error != none {
"items came out harder than you expected"
} else { none },
),
stat-card(
"typical miss",
if abs-error != none { str(calc.round(abs-error * 100)) + " pts" } else { "n/a" },
note: str(predictions.at("within", default: 0)) + " of " + str(n) + " inside tolerance",
),
stat-card(
"discrimination band",
str(predictions.at("band-hit", default: 0)) + " / " + str(predictions.at("band", default: 0)),
note: "landed in the band you expected",
),
)
#grid(
columns: (1fr, 1fr, 1fr),
gutter: 9pt,
stat-card(
"difficulty bias",
if signed-error != none {
(if signed-error >= 0 { "+" } else { "" }) + str(calc.round(signed-error * 100)) + " pts"
} else { "n/a" },
note: if signed-error != none and signed-error > 0 {
"items came out easier than you expected"
} else if signed-error != none {
"items came out harder than you expected"
} else { none },
),
stat-card(
"typical miss",
if abs-error != none { str(calc.round(abs-error * 100)) + " pts" } else { "n/a" },
note: str(predictions.at("within", default: 0)) + " of " + str(n) + " inside tolerance",
),
stat-card(
"discrimination band",
str(predictions.at("band-hit", default: 0)) + " / " + str(predictions.at("band", default: 0)),
note: "landed in the band you expected",
),
)
#{
let surprise = predictions.at("biggest-surprise", default: none)
if surprise != none {
block(above: entry-gap)[
#pad(right: prose-pad)[
#text(size: size-lead, fill: luma(95))[
The largest single gap was question #surprise.number, predicted at
#pct(surprise.expected) and observed at #pct(surprise.observed).
#{
let surprise = predictions.at("biggest-surprise", default: none)
if surprise != none {
block(above: entry-gap)[
#pad(right: prose-pad)[
#text(size: size-lead, fill: luma(95))[
The largest single gap was question #surprise.number, predicted at
#pct(surprise.expected) and observed at #pct(surprise.observed).
]
]
]
]
}
}
}
]
]
// ─────────────────────────────────────────────────────────────────────────────
+27 -10
View File
@@ -143,6 +143,7 @@
review: ((citation: "KKW §6.2", title: "Molecules and Medicine", url: "https://example.edu/6/2"),),
),
),
dropped-questions: ((number: 35, full-credit: true),),
review-lectures: (
(
lecture: "L1.1",
@@ -458,15 +459,30 @@
#block(above: entry-gap)[
#pad(right: prose-pad)[
#text(size: size-lead)[
#plural(dropped-questions.len(), "Question", "Questions")
#dropped-questions.map(str).join(", ")
#plural(dropped-questions.len(), "was", "were")
dropped after the exam and #plural(dropped-questions.len(), "is", "are")
not part of anyone's score. Your percentage above is out of the
#plural(dropped-questions.len(), "remaining question", "remaining questions").
Nothing you wrote on
#plural(dropped-questions.len(), "it", "them")
counted for or against you.
#{
// Two kinds of drop, and they need different sentences. A credited
// question is still in the denominator, so telling a student it was
// removed would not match the arithmetic they can do themselves.
let credited = dropped-questions.filter(d => d.at("full-credit", default: false))
let removed = dropped-questions.filter(d => not d.at("full-credit", default: false))
let numbers = list => list.map(d => str(d.number)).join(", ")
if credited.len() > 0 [
#plural(credited.len(), "Question", "Questions") #numbers(credited)
#plural(credited.len(), "was", "were") thrown out after the exam.
Everyone received full credit for
#plural(credited.len(), "it", "them"), so
#plural(credited.len(), "it is", "they are") still counted in the
score above and whatever you chose made no difference.
]
if removed.len() > 0 [
#plural(removed.len(), "Question", "Questions") #numbers(removed)
#plural(removed.len(), "was", "were") thrown out and removed from
scoring, so your percentage is out of the remaining questions.
Nothing you wrote on #plural(removed.len(), "it", "them") counted
either way.
]
}
]
]
]
@@ -822,7 +838,8 @@
#box(width: 0.7em, height: 0.7em, fill: ok-color.lighten(70%), radius: 2pt) right ·
#box(width: 0.7em, height: 0.7em, fill: mid-color.lighten(70%), radius: 2pt) part marks ·
#box(width: 0.7em, height: 0.7em, fill: bad-color.lighten(70%), radius: 2pt) not right ·
#box(width: 0.7em, height: 0.7em, fill: luma(210), radius: 2pt) left blank
#box(width: 0.7em, height: 0.7em, fill: luma(210), radius: 2pt) left blank ·
#box(width: 0.7em, height: 0.7em, fill: luma(150), radius: 2pt) dropped, not scored
]
]
]