Compare commits

..
2 Commits
Author SHA1 Message Date
alexm cea03048b8 feat: improve dropped question support 2026-09-20 00:41:26 -04:00
alexm ba84c4d82a chore: add dropped question to reports 2026-09-19 23:15:31 -04:00
18 changed files with 387 additions and 66 deletions
+34
View File
@@ -396,6 +396,39 @@ pub fn analyze(
)); ));
} }
// A drop changes every student's percentage, so the two ways to get it wrong
// are worth saying out loud. Both are silent otherwise: the numbers simply
// come out different from the platform's.
if let Some(record) = record {
for placement in record.items.iter().filter(|p| p.dropped) {
let rows = set.for_item(placement.number);
if rows.is_empty() {
continue;
}
let all_credited = rows.iter().all(|r| r.credit >= 0.999);
if placement.dropped_with_credit() && !all_credited {
let short = rows.iter().filter(|r| r.credit < 0.999).count();
warnings.push(format!(
"question {} is marked `dropped_as: full_credit`, but {short} of {} responses \
carry less than full credit. Either the platform was not regraded or the \
export predates the regrade; until one of those is fixed this report's \
percentages will sit below the grade of record",
placement.number,
rows.len()
));
}
if !placement.dropped_with_credit() && all_credited {
warnings.push(format!(
"question {} is dropped and every response carries full credit, which is what \
crediting every option on the platform looks like. It is being removed from \
the denominator here, so this report will read slightly lower than the \
platform. Set `dropped_as: full_credit` if the platform kept the point",
placement.number
));
}
}
}
let mut items = Vec::new(); let mut items = Vec::new();
let mut p_values = Vec::new(); let mut p_values = Vec::new();
let mut rpbs = Vec::new(); let mut rpbs = Vec::new();
@@ -986,6 +1019,7 @@ mod tests {
topics: vec![], topics: vec![],
bonus: false, bonus: false,
dropped: false, dropped: false,
dropped_full_credit: false,
} }
} }
+61 -1
View File
@@ -172,6 +172,14 @@ pub struct StudentDiagnostic {
/// One row per question, with no question in it. /// One row per question, with no question in it.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub questions: Vec<QuestionRow>, pub questions: Vec<QuestionRow>,
/// Questions thrown out, in number order.
///
/// Reported separately from the question rows so the document can say once,
/// in prose, what happened to them. The two kinds need different sentences:
/// a removed question is gone from the denominator, and a full-credit
/// question is still in it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub dropped_questions: Vec<DroppedQuestion>,
/// Which lectures to go back to, the one that would repay the most time /// Which lectures to go back to, the one that would repay the most time
/// first. /// first.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
@@ -320,6 +328,13 @@ pub struct QuestionRow {
/// Whether it was a bonus question. /// Whether it was a bonus question.
#[serde(skip_serializing_if = "std::ops::Not::not")] #[serde(skip_serializing_if = "std::ops::Not::not")]
pub bonus: bool, pub bonus: bool,
/// Whether it was dropped from scoring after the fact.
///
/// A dropped question still appears in the map, because the student has the
/// paper in front of them and will look for it. What it must not do is
/// appear as an error they made.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub dropped: bool,
/// Whether the student left it blank. /// Whether the student left it blank.
pub blank: bool, pub blank: bool,
/// The share of the class that answered it correctly, when comparison is on. /// The share of the class that answered it correctly, when comparison is on.
@@ -347,6 +362,17 @@ pub struct QuestionRow {
pub review: Vec<ItemReading>, pub review: Vec<ItemReading>,
} }
/// One question thrown out after the exam.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct DroppedQuestion {
/// The recorded question number.
pub number: u32,
/// Whether the drop was applied by crediting every option, in which case the
/// question is still in the points of record.
pub full_credit: bool,
}
/// One citation to read again after missing a question. /// One citation to read again after missing a question.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
@@ -530,6 +556,17 @@ pub fn student(
Vec::new() Vec::new()
}; };
let mut dropped_questions: Vec<DroppedQuestion> = rows
.iter()
.filter(|r| r.dropped)
.map(|r| DroppedQuestion {
number: r.item_number,
full_credit: r.dropped_full_credit,
})
.collect();
dropped_questions.sort_by_key(|d| d.number);
dropped_questions.dedup_by_key(|d| d.number);
// Built from the response rows rather than from `questions`, so a report with // Built from the response rows rather than from `questions`, so a report with
// `--no-questions` still says where to go back to; it just does not name the // `--no-questions` still says where to go back to; it just does not name the
// question numbers. // question numbers.
@@ -573,6 +610,7 @@ pub fn student(
strengths, strengths,
focus, focus,
questions, questions,
dropped_questions,
review_lectures, review_lectures,
study, study,
} }
@@ -591,7 +629,10 @@ fn question_row(
opts: &Options, opts: &Options,
) -> QuestionRow { ) -> QuestionRow {
let blank = row.selected.is_empty() && row.eliminated.is_empty(); let blank = row.selected.is_empty() && row.eliminated.is_empty();
let missed = row.credit < 0.999; // A dropped question cannot be missed. Without `counts()` here, a question
// thrown out after the exam still collects per-option feedback explaining an
// error the student is no longer being charged for.
let missed = row.credit < 0.999 && row.counts();
let mut feedback = None; let mut feedback = None;
let mut hint = None; let mut hint = None;
let mut misconception = None; let mut misconception = None;
@@ -673,6 +714,7 @@ fn question_row(
correct: row.correct, correct: row.correct,
credit: row.credit, credit: row.credit,
bonus: row.bonus, bonus: row.bonus,
dropped: row.dropped,
blank, blank,
class_rate: opts class_rate: opts
.comparison .comparison
@@ -926,6 +968,11 @@ pub struct CohortDiagnostic {
pub lectures: Vec<CohortLectureRow>, pub lectures: Vec<CohortLectureRow>,
/// Per-question statistics. /// Per-question statistics.
pub questions: Vec<CohortQuestionRow>, pub questions: Vec<CohortQuestionRow>,
/// Questions thrown out, which are therefore absent from every table above.
/// Recorded so the report says why rather than leaving a gap in the
/// numbering.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub dropped_questions: Vec<DroppedQuestion>,
/// What to do about each question that raised something. /// What to do about each question that raised something.
pub triage: Triage, pub triage: Triage,
/// How the authored expectations did. /// How the authored expectations did.
@@ -1478,6 +1525,17 @@ pub fn cohort(
.filter_map(|item| questions.iter().find(|q| q.number == item.number).cloned()) .filter_map(|item| questions.iter().find(|q| q.number == item.number).cloned())
.collect(); .collect();
let mut dropped_questions: Vec<DroppedQuestion> = set
.all_items()
.into_iter()
.filter(|n| set.item_dropped(*n))
.map(|number| DroppedQuestion {
number,
full_credit: set.for_item(number).iter().any(|r| r.dropped_full_credit),
})
.collect();
dropped_questions.sort_by_key(|d| d.number);
let default_options = course.policy.options_per_item; let default_options = course.policy.options_per_item;
let triage = triage(&questions, threshold, default_options); let triage = triage(&questions, threshold, default_options);
let predictions = prediction_summary(analysis); let predictions = prediction_summary(analysis);
@@ -1490,6 +1548,7 @@ pub fn cohort(
distribution: distribution(&percents), distribution: distribution(&percents),
grades, grades,
lectures, lectures,
dropped_questions,
triage, triage,
predictions, predictions,
reliability: ReliabilityRow { reliability: ReliabilityRow {
@@ -2193,6 +2252,7 @@ mod tests {
strengths: Vec::new(), strengths: Vec::new(),
focus: Vec::new(), focus: Vec::new(),
questions: Vec::new(), questions: Vec::new(),
dropped_questions: Vec::new(),
review_lectures: Vec::new(), review_lectures: Vec::new(),
study: Vec::new(), study: Vec::new(),
}) })
+1
View File
@@ -1112,6 +1112,7 @@ mod tests {
topics: vec![], topics: vec![],
bonus: false, bonus: false,
dropped: false, dropped: false,
dropped_full_credit: false,
} }
} }
+1
View File
@@ -468,6 +468,7 @@ pub fn to_record(
learning_objectives: e.item.learning_objectives.clone(), learning_objectives: e.item.learning_objectives.clone(),
credit_overrides: BTreeMap::new(), credit_overrides: BTreeMap::new(),
dropped: false, dropped: false,
dropped_as: None,
}); });
} }
+1
View File
@@ -351,6 +351,7 @@ pub fn ingest(
topics: Vec::new(), topics: Vec::new(),
bonus: false, bonus: false,
dropped: false, dropped: false,
dropped_full_credit: false,
}); });
} }
} }
+1
View File
@@ -709,6 +709,7 @@ mod tests {
topics: Vec::new(), topics: Vec::new(),
bonus: false, bonus: false,
dropped: false, dropped: false,
dropped_full_credit: false,
} }
} }
+1
View File
@@ -659,6 +659,7 @@ pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
topics: Vec::new(), topics: Vec::new(),
bonus, bonus,
dropped: false, dropped: false,
dropped_full_credit: false,
}); });
} }
} }
+48 -5
View File
@@ -108,10 +108,26 @@ pub struct Response {
pub bonus: bool, pub bonus: bool,
/// Whether the item was dropped after the fact. /// Whether the item was dropped after the fact.
pub dropped: bool, pub dropped: bool,
/// Whether that drop was applied by crediting every option, so the item is
/// still part of the points of record even though it is out of the
/// statistics.
///
/// Written by [`ResponseSet::enrich`] from the placement's `dropped_as`.
#[serde(default)]
pub dropped_full_credit: bool,
} }
impl Response { impl Response {
/// Whether this row should count toward scored totals and item statistics. /// Whether this row counts as evidence.
///
/// Evidence means item statistics, objective mastery, level rates, and the
/// IRT fit. A dropped item is never evidence, however the drop was applied:
/// an item everyone was given has no variance to contribute and would only
/// flatter the objective it was written against.
///
/// This is deliberately not the same question as [`Response::scores`]. The
/// two were one predicate until dropping a question stopped always meaning
/// removing it.
/// ///
/// # Returns /// # Returns
/// ///
@@ -120,6 +136,21 @@ impl Response {
!self.bonus && !self.dropped !self.bonus && !self.dropped
} }
/// Whether this row counts toward the points of record.
///
/// A question dropped by crediting every option still sits in the student's
/// total on the platform, and a report that disagreed with the platform
/// about a student's percentage would be worse than no report. So a
/// full-credit drop stays in both the numerator and the denominator here,
/// while a removed drop leaves both.
///
/// # Returns
///
/// `true` when the row belongs in the score.
pub fn scores(&self) -> bool {
!self.bonus && (!self.dropped || self.dropped_full_credit)
}
/// The response coded for a dichotomous model. /// The response coded for a dichotomous model.
/// ///
/// Partial credit is rounded toward the majority: a half-credit response is /// Partial credit is rounded toward the majority: a half-credit response is
@@ -188,7 +219,11 @@ impl ResponseSet {
set.into_iter().map(|s| s.to_string()).collect() set.into_iter().map(|s| s.to_string()).collect()
} }
/// The distinct item numbers that count toward the scored total, sorted. /// The distinct item numbers that count as evidence, sorted.
///
/// Named for the scored total it once described; it is the analysis matrix's
/// item list, so it uses [`Response::counts`] and excludes every dropped
/// item. [`ResponseSet::points_available`] is the scoring denominator.
pub fn scored_items(&self) -> Vec<u32> { pub fn scored_items(&self) -> Vec<u32> {
let set: BTreeSet<u32> = self let set: BTreeSet<u32> = self
.rows .rows
@@ -247,11 +282,12 @@ impl ResponseSet {
/// ///
/// # Returns /// # Returns
/// ///
/// The sum of `score` over scored, undropped items. /// The sum of `score` over the items that count toward the score, which
/// includes a question dropped by crediting every option.
pub fn scored_total(&self, key: &str) -> f64 { pub fn scored_total(&self, key: &str) -> f64 {
self.rows self.rows
.iter() .iter()
.filter(|r| r.student_key == key && r.counts()) .filter(|r| r.student_key == key && r.scores())
.map(|r| r.score) .map(|r| r.score)
.sum() .sum()
} }
@@ -269,7 +305,7 @@ impl ResponseSet {
/// for each item so a student who skipped an item still has a denominator. /// for each item so a student who skipped an item still has a denominator.
pub fn points_available(&self) -> f64 { pub fn points_available(&self) -> f64 {
let mut per_item: BTreeMap<u32, f64> = BTreeMap::new(); let mut per_item: BTreeMap<u32, f64> = BTreeMap::new();
for r in self.rows.iter().filter(|r| r.counts()) { for r in self.rows.iter().filter(|r| r.scores()) {
let e = per_item.entry(r.item_number).or_insert(0.0); let e = per_item.entry(r.item_number).or_insert(0.0);
if r.points_possible > *e { if r.points_possible > *e {
*e = r.points_possible; *e = r.points_possible;
@@ -382,6 +418,7 @@ impl ResponseSet {
r.item_version = p.version; r.item_version = p.version;
r.bonus = r.bonus || p.bonus; r.bonus = r.bonus || p.bonus;
r.dropped = r.dropped || p.dropped; r.dropped = r.dropped || p.dropped;
r.dropped_full_credit = r.dropped_full_credit || p.dropped_with_credit();
if let Some(points) = p.points { if let Some(points) = p.points {
// The record is authoritative for points as administered; the // The record is authoritative for points as administered; the
// export sometimes carries a stale maximum. // export sometimes carries a stale maximum.
@@ -636,6 +673,9 @@ pub struct FlatResponse {
/// Comma-joined eliminated letters in the bank's lettering. /// Comma-joined eliminated letters in the bank's lettering.
#[serde(default)] #[serde(default)]
pub eliminated_source: String, pub eliminated_source: String,
/// Whether a dropped item was dropped by crediting every option.
#[serde(default)]
pub dropped_full_credit: bool,
} }
impl FlatResponse { impl FlatResponse {
@@ -656,6 +696,7 @@ impl FlatResponse {
assessment_id: r.assessment_id.clone(), assessment_id: r.assessment_id.clone(),
date: r.date.map(|d| d.to_string()).unwrap_or_default(), date: r.date.map(|d| d.to_string()).unwrap_or_default(),
form: r.form.clone().unwrap_or_default(), form: r.form.clone().unwrap_or_default(),
dropped_full_credit: r.dropped_full_credit,
form_position: r.form_position.unwrap_or(0), form_position: r.form_position.unwrap_or(0),
student_key: r.student_key.clone(), student_key: r.student_key.clone(),
sid: r.sid.clone().unwrap_or_default(), sid: r.sid.clone().unwrap_or_default(),
@@ -709,6 +750,7 @@ impl FlatResponse {
assessment_id: self.assessment_id.clone(), assessment_id: self.assessment_id.clone(),
date: self.date.parse().ok(), date: self.date.parse().ok(),
form: none_if_empty(&self.form), form: none_if_empty(&self.form),
dropped_full_credit: self.dropped_full_credit,
form_position: if self.form_position == 0 { form_position: if self.form_position == 0 {
None None
} else { } else {
@@ -792,6 +834,7 @@ mod tests {
topics: vec![], topics: vec![],
bonus: false, bonus: false,
dropped: false, dropped: false,
dropped_full_credit: false,
} }
} }
+1
View File
@@ -564,6 +564,7 @@ mod tests {
topics: vec![], topics: vec![],
bonus: false, bonus: false,
dropped: false, dropped: false,
dropped_full_credit: false,
} }
} }
+10
View File
@@ -62,6 +62,7 @@ pub fn schema() -> Schema {
Field::new("bonus", DataType::Boolean, false), Field::new("bonus", DataType::Boolean, false),
Field::new("dropped", DataType::Boolean, false), Field::new("dropped", DataType::Boolean, false),
Field::new("form_position", DataType::UInt32, false), Field::new("form_position", DataType::UInt32, false),
Field::new("dropped_full_credit", DataType::Boolean, false),
Field::new("selected_source", DataType::Utf8, false), Field::new("selected_source", DataType::Utf8, false),
Field::new("eliminated_source", DataType::Utf8, false), Field::new("eliminated_source", DataType::Utf8, false),
]) ])
@@ -124,6 +125,7 @@ fn to_batch(rows: &[FlatResponse]) -> Result<RecordBatch> {
boolc(|r| r.bonus), boolc(|r| r.bonus),
boolc(|r| r.dropped), boolc(|r| r.dropped),
u32c(|r| r.form_position), u32c(|r| r.form_position),
boolc(|r| r.dropped_full_credit),
s(|r| &r.selected_source), s(|r| &r.selected_source),
s(|r| &r.eliminated_source), s(|r| &r.eliminated_source),
]; ];
@@ -256,6 +258,11 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
.column_by_name(name) .column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<UInt32Array>()) .and_then(|c| c.as_any().downcast_ref::<UInt32Array>())
}; };
let optional_bools = |name: &str| -> Option<&BooleanArray> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
};
let administration_id = strings("administration_id")?; let administration_id = strings("administration_id")?;
let course = strings("course")?; let course = strings("course")?;
@@ -284,6 +291,7 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
let dropped = bools("dropped")?; let dropped = bools("dropped")?;
let form_position = optional_uints("form_position"); let form_position = optional_uints("form_position");
let dropped_full_credit = optional_bools("dropped_full_credit");
let selected_source = optional_strings("selected_source"); let selected_source = optional_strings("selected_source");
let eliminated_source = optional_strings("eliminated_source"); let eliminated_source = optional_strings("eliminated_source");
@@ -322,6 +330,7 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
topics: topics.value(i).to_string(), topics: topics.value(i).to_string(),
bonus: bonus.value(i), bonus: bonus.value(i),
dropped: dropped.value(i), dropped: dropped.value(i),
dropped_full_credit: dropped_full_credit.map(|a| a.value(i)).unwrap_or(false),
}); });
} }
Ok(out) Ok(out)
@@ -367,6 +376,7 @@ mod tests {
topics: "kinetics".into(), topics: "kinetics".into(),
bonus: false, bonus: false,
dropped: false, dropped: false,
dropped_full_credit: false,
form_position: 0, form_position: 0,
selected_source: String::new(), selected_source: String::new(),
eliminated_source: String::new(), eliminated_source: String::new(),
+2
View File
@@ -602,6 +602,7 @@ items:
learning_objectives: Vec::new(), learning_objectives: Vec::new(),
credit_overrides: Default::default(), credit_overrides: Default::default(),
dropped: false, dropped: false,
dropped_as: None,
}, },
Placement { Placement {
number: 2, number: 2,
@@ -615,6 +616,7 @@ items:
learning_objectives: Vec::new(), learning_objectives: Vec::new(),
credit_overrides: Default::default(), credit_overrides: Default::default(),
dropped: false, dropped: false,
dropped_as: None,
}, },
], ],
} }
+2
View File
@@ -1377,6 +1377,7 @@ items:
learning_objectives: Vec::new(), learning_objectives: Vec::new(),
credit_overrides: Default::default(), credit_overrides: Default::default(),
dropped: false, dropped: false,
dropped_as: None,
}, },
Placement { Placement {
number: 2, number: 2,
@@ -1390,6 +1391,7 @@ items:
learning_objectives: Vec::new(), learning_objectives: Vec::new(),
credit_overrides: Default::default(), credit_overrides: Default::default(),
dropped: false, dropped: false,
dropped_as: None,
}, },
], ],
} }
+2
View File
@@ -866,6 +866,7 @@ items:
learning_objectives: Vec::new(), learning_objectives: Vec::new(),
credit_overrides: Default::default(), credit_overrides: Default::default(),
dropped: false, dropped: false,
dropped_as: None,
}, },
Placement { Placement {
number: 2, number: 2,
@@ -879,6 +880,7 @@ items:
learning_objectives: Vec::new(), learning_objectives: Vec::new(),
credit_overrides: Default::default(), credit_overrides: Default::default(),
dropped: false, dropped: false,
dropped_as: None,
}, },
], ],
} }
+2
View File
@@ -427,6 +427,7 @@ mod tests {
learning_objectives: Vec::new(), learning_objectives: Vec::new(),
credit_overrides: Default::default(), credit_overrides: Default::default(),
dropped: true, dropped: true,
dropped_as: None,
}, },
Placement { Placement {
number: 2, number: 2,
@@ -440,6 +441,7 @@ mod tests {
learning_objectives: Vec::new(), learning_objectives: Vec::new(),
credit_overrides: Default::default(), credit_overrides: Default::default(),
dropped: false, dropped: false,
dropped_as: None,
}, },
], ],
}; };
+34
View File
@@ -299,6 +299,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
value.insert_some("correct", question.correct.map(Value::Bool)); value.insert_some("correct", question.correct.map(Value::Bool));
value.insert("credit", Value::Float(question.credit)); value.insert("credit", Value::Float(question.credit));
value.insert("bonus", Value::Bool(question.bonus)); value.insert("bonus", Value::Bool(question.bonus));
value.insert("dropped", Value::Bool(question.dropped));
value.insert("blank", Value::Bool(question.blank)); value.insert("blank", Value::Bool(question.blank));
value.insert_some("class-rate", question.class_rate.map(Value::Float)); value.insert_some("class-rate", question.class_rate.map(Value::Float));
value.insert( value.insert(
@@ -348,6 +349,22 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
), ),
); );
out.insert(
"dropped-questions",
Value::Array(
diagnostic
.dropped_questions
.iter()
.map(|dropped| {
let mut value = Value::dict();
value.insert("number", Value::Int(dropped.number as i64));
value.insert("full-credit", Value::Bool(dropped.full_credit));
value
})
.collect(),
),
);
out.insert( out.insert(
"review-lectures", "review-lectures",
Value::Array( Value::Array(
@@ -612,6 +629,22 @@ pub fn cohort_value(diagnostic: &CohortDiagnostic, config: &RenderConfig) -> Val
), ),
); );
out.insert(
"dropped-questions",
Value::Array(
diagnostic
.dropped_questions
.iter()
.map(|dropped| {
let mut value = Value::dict();
value.insert("number", Value::Int(dropped.number as i64));
value.insert("full-credit", Value::Bool(dropped.full_credit));
value
})
.collect(),
),
);
let triage_rows = |rows: &[crate::diagnostic::TriageRow]| -> Value { let triage_rows = |rows: &[crate::diagnostic::TriageRow]| -> Value {
Value::Array(rows.iter().map(|row| triage_value(row, content)).collect()) Value::Array(rows.iter().map(|row| triage_value(row, content)).collect())
}; };
@@ -1128,6 +1161,7 @@ mod tests {
gaps: Vec::new(), gaps: Vec::new(),
grades: Vec::new(), grades: Vec::new(),
lectures: Vec::new(), lectures: Vec::new(),
dropped_questions: Vec::new(),
questions: Vec::new(), questions: Vec::new(),
triage: crate::diagnostic::Triage::default(), triage: crate::diagnostic::Triage::default(),
predictions: crate::diagnostic::PredictionSummary::default(), predictions: crate::diagnostic::PredictionSummary::default(),
+79 -55
View File
@@ -167,6 +167,7 @@
band-hit: 11, band-hit: 11,
biggest-surprise: (number: 14, expected: 0.45, observed: 0.86), biggest-surprise: (number: 14, expected: 0.45, observed: 0.86),
), ),
dropped-questions: (),
revise: (), revise: (),
forms: ( forms: (
(id: "A", students: 12, mean: 73.5, sd: 10.2), (id: "A", students: 12, mean: 73.5, sd: 10.2),
@@ -989,6 +990,27 @@
One row per question, in the order they were numbered. Option letters are the One row per question, in the order they were numbered. Option letters are the
bank's, not any one form's. See the guide above for what p, r, and D mean; the bank's, not any one form's. See the guide above for what p, r, and D mean; the
r column is colour-coded against the conventional cut points. r column is colour-coded against the conventional cut points.
#{
let dropped = cb-data.at("dropped-questions", default: ())
if dropped.len() > 0 [
#plural(dropped.len(), "Question", "Questions")
#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.
]
}
]
}
] ]
#table( #table(
@@ -1161,69 +1183,71 @@
#let predictions = cb-data.at("predictions", default: (:)) #let predictions = cb-data.at("predictions", default: (:))
#if show-predictions and predictions.at("predicted", default: 0) > 0 [ #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 n = predictions.at("predicted", default: 0)
#let calibrated = predictions.at("calibrated", default: 0) #let calibrated = predictions.at("calibrated", default: 0)
#let signed-error = predictions.at("mean-signed-error", default: none) #let signed-error = predictions.at("mean-signed-error", default: none)
#let abs-error = predictions.at("mean-abs-error", default: none) #let abs-error = predictions.at("mean-abs-error", default: none)
#explain[ #explain[
#if calibrated == 0 [ #if calibrated == 0 [
None of these #n #plural(n, "expectation", "expectations") rests on prior None of these #n #plural(n, "expectation", "expectations") rests on prior
data, so they are predictions rather than calibrations. A prediction that 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 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 summarised here instead of appearing #n times in the tables above. Once
`coursebank calibrate` has written statistics back into the bank, a `coursebank calibrate` has written statistics back into the bank, a
subsequent miss means the cohort or the teaching moved, and it will be subsequent miss means the cohort or the teaching moved, and it will be
flagged. flagged.
] else [ ] else [
#calibrated of #n #plural(n, "expectation", "expectations") rests on a #calibrated of #n #plural(n, "expectation", "expectations") rests on a
prior calibration. Those are the ones whose misses are flagged on the item, 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 because a calibrated item that moves is telling you about this cohort. The
rest are predictions, and a miss corrects the prediction. rest are predictions, and a miss corrects the prediction.
]
] ]
]
#grid( #grid(
columns: (1fr, 1fr, 1fr), columns: (1fr, 1fr, 1fr),
gutter: 9pt, gutter: 9pt,
stat-card( stat-card(
"difficulty bias", "difficulty bias",
if signed-error != none { if signed-error != none {
(if signed-error >= 0 { "+" } else { "" }) + str(calc.round(signed-error * 100)) + " pts" (if signed-error >= 0 { "+" } else { "" }) + str(calc.round(signed-error * 100)) + " pts"
} else { "n/a" }, } else { "n/a" },
note: if signed-error != none and signed-error > 0 { note: if signed-error != none and signed-error > 0 {
"items came out easier than you expected" "items came out easier than you expected"
} else if signed-error != none { } else if signed-error != none {
"items came out harder than you expected" "items came out harder than you expected"
} else { none }, } else { none },
), ),
stat-card( stat-card(
"typical miss", "typical miss",
if abs-error != none { str(calc.round(abs-error * 100)) + " pts" } else { "n/a" }, 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", note: str(predictions.at("within", default: 0)) + " of " + str(n) + " inside tolerance",
), ),
stat-card( stat-card(
"discrimination band", "discrimination band",
str(predictions.at("band-hit", default: 0)) + " / " + str(predictions.at("band", default: 0)), str(predictions.at("band-hit", default: 0)) + " / " + str(predictions.at("band", default: 0)),
note: "landed in the band you expected", note: "landed in the band you expected",
), ),
) )
#{ #{
let surprise = predictions.at("biggest-surprise", default: none) let surprise = predictions.at("biggest-surprise", default: none)
if surprise != none { if surprise != none {
block(above: entry-gap)[ block(above: entry-gap)[
#pad(right: prose-pad)[ #pad(right: prose-pad)[
#text(size: size-lead, fill: luma(95))[ #text(size: size-lead, fill: luma(95))[
The largest single gap was question #surprise.number, predicted at The largest single gap was question #surprise.number, predicted at
#pct(surprise.expected) and observed at #pct(surprise.observed). #pct(surprise.expected) and observed at #pct(surprise.observed).
]
] ]
] ]
] }
} }
} ]
] ]
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
+51 -5
View File
@@ -121,6 +121,7 @@
correct: true, correct: true,
credit: 1.0, credit: 1.0,
bonus: false, bonus: false,
dropped: false,
blank: false, blank: false,
class-rate: 0.91, class-rate: 0.91,
taught-in: (), taught-in: (),
@@ -142,6 +143,7 @@
review: ((citation: "KKW §6.2", title: "Molecules and Medicine", url: "https://example.edu/6/2"),), review: ((citation: "KKW §6.2", title: "Molecules and Medicine", url: "https://example.edu/6/2"),),
), ),
), ),
dropped-questions: ((number: 35, full-credit: true),),
review-lectures: ( review-lectures: (
( (
lecture: "L1.1", lecture: "L1.1",
@@ -451,6 +453,41 @@
}, },
) )
#let dropped-questions = cb-data.at("dropped-questions", default: ())
#if dropped-questions.len() > 0 [
#block(above: entry-gap)[
#pad(right: prose-pad)[
#text(size: size-lead)[
#{
// 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.
]
}
]
]
]
]
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Levels // Levels
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -750,7 +787,10 @@
#let questions = cb-data.at("questions", default: ()) #let questions = cb-data.at("questions", default: ())
#let question-box(q) = { #let question-box(q) = {
let color = if q.at("blank", default: false) { luma(160) } else if q.at("correct", default: false) == true { let dropped = q.at("dropped", default: false)
let color = if dropped { luma(130) } else if q.at("blank", default: false) {
luma(160)
} else if q.at("correct", default: false) == true {
ok-color ok-color
} else if q.at("credit", default: 0.0) > 0.0 { mid-color } else { bad-color } } else if q.at("credit", default: 0.0) > 0.0 { mid-color } else { bad-color }
box( box(
@@ -763,10 +803,15 @@
#align(center)[ #align(center)[
#text(size: size-small, weight: "bold", fill: color.darken(18%))[#q.number] #text(size: size-small, weight: "bold", fill: color.darken(18%))[#q.number]
#{ #{
let level = q.at("level", default: none) if dropped {
if level != none {
linebreak() linebreak()
text(size: size-tag, fill: luma(120))[L#level] text(size: size-tag, fill: luma(110))[out]
} else {
let level = q.at("level", default: none)
if level != none {
linebreak()
text(size: size-tag, fill: luma(120))[L#level]
}
} }
} }
] ]
@@ -793,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: 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: 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: 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
] ]
] ]
] ]
+56
View File
@@ -281,6 +281,48 @@ pub struct Placement {
/// Set when an item was dropped from scoring after administration. /// Set when an item was dropped from scoring after administration.
#[serde(default, skip_serializing_if = "is_false")] #[serde(default, skip_serializing_if = "is_false")]
pub dropped: bool, pub dropped: bool,
/// How the drop was applied on the grading platform.
///
/// Two ways to throw a question out, and they produce different
/// percentages. [`DropStyle::Removed`] takes the item out of the numerator
/// and the denominator: a student with 27 of 35 scores 77.1%.
/// [`DropStyle::FullCredit`] is what you do when the grade of record lives
/// somewhere else and the question cannot be removed from it: every option
/// is keyed, everyone earns the point, and the same student scores 28 of 36,
/// or 77.8%.
///
/// Defaults to [`DropStyle::Removed`], which is what `dropped: true` meant
/// before this field existed. Set it to `full_credit` when you have credited
/// every option in the platform, so that the report agrees with the grade the
/// student can see.
///
/// Either way the item is out of the item statistics, the objective
/// evidence, and the IRT fit: an item everyone got right has no variance to
/// contribute.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dropped_as: Option<DropStyle>,
}
/// How a dropped item was handled on the grading platform.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DropStyle {
/// Taken out of the numerator and the denominator.
Removed,
/// Every option credited, so the item stays in both.
FullCredit,
}
impl Placement {
/// Whether this placement was dropped by crediting every option.
///
/// # Returns
///
/// `true` only when the item is dropped *and* the drop was applied as full
/// credit, so the item still belongs in the points of record.
pub fn dropped_with_credit(&self) -> bool {
self.dropped && self.dropped_as == Some(DropStyle::FullCredit)
}
} }
impl AssessmentFile { impl AssessmentFile {
@@ -451,6 +493,20 @@ impl AssessmentFile {
)); ));
} }
} }
if p.dropped_as.is_some() && !p.dropped {
issues.push(format!(
"question {}: `dropped_as` is set but `dropped` is not, so nothing is dropped",
p.number
));
}
if p.dropped && !p.credit_overrides.is_empty() {
issues.push(format!(
"question {}: dropped and carrying credit overrides. Pick one: an override \
rescores an option, a drop removes the question",
p.number
));
}
} }
let mut form_ids: Vec<&str> = Vec::new(); let mut form_ids: Vec<&str> = Vec::new();