Handling Dropped Lowest Scores in Weighted Grades
A “drop the lowest N” rule looks trivial until the assignments inside a group carry different point values. Once they do, the score with the smallest raw points is not necessarily the one whose removal helps the student most, and an LMS that promises to “drop the grades that hurt you least” is quietly solving an optimization problem on every recompute. This guide shows how to implement drop-lowest-N correctly inside a weighted assignment group in Python — when the obvious greedy shortcut is provably exact, when it silently overcharges a student, and how to fall back to an exact search that stays cheap in practice. It builds on the group-weighting model in Weighted Grade Calculation Engines and on the final-total reconstruction in Reconstructing Canvas Final Grades in Python; read those first if the weighted-mean formula is unfamiliar.
Why dropping the “lowest” is not obvious
Inside a single group, a student’s group fraction after dropping a set of assignments is:
The goal is to choose the with that maximizes — the grade the student most deserves under a favorable-drop policy. Because both the numerator and the denominator change when you remove an assignment, this is a ratio-optimization, not a simple “sort and chop.” Removing a 4/10 (fraction 0.40) can help less than removing a 18/50 (fraction 0.36) even though 4 is the smaller raw score, and removing a high-percentage-but-high-points item can sometimes raise the average by shrinking the denominator. The brute-force space is every subset, so a group of 20 quizzes dropping the lowest 3 already means 1,140 candidate subsets per student per recompute.
Two practical strategies bound the problem:
- Greedy by per-assignment fraction. Sort the assignments by ascending and drop the first . This is fast () and is what Canvas and most gradebooks actually do. It is exact when every assignment in the group shares the same
points_possible(equal weights inside the group), because then maximizing the mean fraction is identical to discarding the smallest fractions. - Exact by subset search. Enumerate the drop sets, honor the exemptions, and keep the set that yields the largest . This is the ground truth when point values differ and you must guarantee the student’s best legal grade — for an appeal, or when reconciling against a system that optimizes rather than greedily chops.
The engine below computes the greedy answer, computes the exact answer, and reports when they disagree — so you can match your target LMS’s behavior deliberately instead of discovering the gap in a grade dispute.
Prerequisites
Confirm each of these before running the engine — it assumes they hold.
Step-by-step implementation
1. Remove ungraded and excused work before any dropping happens. A drop rule operates on the pool of scored members, so an ungraded assignment must never be a drop candidate — dropping a null would waste the exemption and leave a real low score in place.
2. Separate droppable members from never_drop exemptions. Partition each group’s scored assignments so exempt ids can never be chosen for removal, because a “drop lowest 2” that discards a protected final exam is a policy violation, not an optimization.
3. Cap N at the number of droppable members. If a group has fewer droppable assignments than drop_lowest, drop them all and stop; asking for more raises an error in some engines and silently drops zero in others, so clamp it explicitly.
4. Compute the greedy candidate by sorting on per-assignment fraction. Order the droppable members by ascending and remove the first ; this is the fast path and the exact answer whenever the group’s assignments share one possible value.
5. Compute the exact candidate by enumerating drop subsets. Iterate every -sized subset of droppable members, evaluate for each, and keep the subset that maximizes it — the guaranteed best legal grade regardless of differing point values.
6. Compare the two and flag divergence. When the greedy fraction is lower than the exact fraction, the shortcut is undercharging the student; surface that gap so you can match your LMS deliberately rather than absorb a silent discrepancy in an appeal.
7. Feed the surviving fraction into the weighted mean. Combine each group’s post-drop fraction with its weight exactly as the parent engine does, so the drop logic slots in without changing the outer arithmetic.
Complete runnable code block
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP, getcontext
from itertools import combinations
getcontext().prec = 28
@dataclass(frozen=True)
class Score:
assignment_id: int
earned: Decimal # ungraded/excused already removed upstream
possible: Decimal
@dataclass(frozen=True)
class GroupRule:
group_id: int
weight: Decimal # fractional, e.g. Decimal("0.30")
drop_lowest: int = 0
never_drop: tuple[int, ...] = ()
def tokenize(student_id: str, salt: str = "drop-lowest") -> str:
"""Salted SHA-256 — the raw student id is never carried into results."""
return hashlib.sha256(f"{salt}:{student_id}".encode("utf-8")).hexdigest()
def _fraction(kept: list[Score]) -> Decimal:
"""Group fraction f(D) over the assignments that survive the drop."""
possible = sum((s.possible for s in kept), Decimal(0))
if possible == 0:
return Decimal(0)
earned = sum((s.earned for s in kept), Decimal(0))
return earned / possible
def _greedy_kept(scores: list[Score], droppable: list[Score], n: int) -> list[Score]:
"""Drop the N members with the smallest per-assignment fraction."""
ranked = sorted(droppable, key=lambda s: (s.earned / s.possible, s.assignment_id))
dropped = {id(s) for s in ranked[:n]}
return [s for s in scores if id(s) not in dropped]
def _exact_kept(scores: list[Score], droppable: list[Score], n: int) -> list[Score]:
"""Search every N-drop subset and keep the one maximizing the group fraction."""
best_kept, best_frac = scores, Decimal(-1)
for combo in combinations(droppable, n):
drop_ids = {id(s) for s in combo}
kept = [s for s in scores if id(s) not in drop_ids]
frac = _fraction(kept)
if frac > best_frac:
best_kept, best_frac = kept, frac
return best_kept
def resolve_group(rule: GroupRule, scores: list[Score]) -> dict:
"""Apply drop-lowest-N under both strategies and report the surviving fraction."""
droppable = [s for s in scores if s.assignment_id not in rule.never_drop]
n = min(rule.drop_lowest, len(droppable)) # never drop more than exist
greedy = _fraction(_greedy_kept(scores, droppable, n))
exact = _fraction(_exact_kept(scores, droppable, n))
return {
"group_id": rule.group_id,
"weight": rule.weight,
"greedy_fraction": greedy,
"exact_fraction": exact,
"greedy_undercharges": exact > greedy, # shortcut hurt the student
"dropped_count": n,
}
def final_grade(student_id: str, rules: list[GroupRule],
by_group: dict[int, list[Score]], use_exact: bool = True) -> dict:
"""Combine post-drop group fractions into the weighted course total."""
weighted_sum, live_weight = Decimal(0), Decimal(0)
for rule in rules:
res = resolve_group(rule, by_group.get(rule.group_id, []))
frac = res["exact_fraction"] if use_exact else res["greedy_fraction"]
if not by_group.get(rule.group_id):
continue
weighted_sum += rule.weight * frac
live_weight += rule.weight
pct = (weighted_sum / live_weight * Decimal(100)) if live_weight else Decimal(0)
return {
"student_token": tokenize(student_id),
"grade_pct": pct.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP),
}
if __name__ == "__main__":
# Quizzes group, drop lowest 1. Points differ, so greedy and exact can split:
# dropping 4/10 (0.40) shrinks the mean less than dropping 18/50 (0.36).
scores = [
Score(1, Decimal("4"), Decimal("10")), # fraction 0.40
Score(2, Decimal("18"), Decimal("50")), # fraction 0.36
Score(3, Decimal("9"), Decimal("10")), # fraction 0.90
]
rule = GroupRule(201, Decimal("1.0"), drop_lowest=1)
res = resolve_group(rule, scores)
print(f"greedy fraction : {res['greedy_fraction'].quantize(Decimal('0.0001'))}")
print(f"exact fraction : {res['exact_fraction'].quantize(Decimal('0.0001'))}")
print(f"greedy undercharges student: {res['greedy_undercharges']}")
grade = final_grade("stu-placeholder-5510", [rule], {201: scores}, use_exact=True)
print(f"token : {grade['student_token'][:16]}…")
print(f"exact grade : {grade['grade_pct']}%")
Verification and output validation
Confirm the drop logic before trusting the recomputed grade:
- Greedy and exact diverge on unequal points. With the sample quizzes, greedy drops assignment
2(raw fraction 0.36) and yields13/20 = 0.65, while exact also compares dropping assignment1— verifygreedy_underchargesreports which subset actually maximizes for your data. - Equal points collapse the gap. Reset every
possibleto the same value; thengreedy_fraction == exact_fractionfor all N, confirming the greedy shortcut is exact under equal weighting. - Exemptions are never dropped. Add the group’s highest-stakes assignment to
never_dropand assert it appears in the kept set of both strategies regardless of its fraction. - N is clamped. Set
drop_lowestabove the number of droppable members and confirmdropped_countequals the droppable count, not the requested N, and that no exception is raised. - Token, not identifier.
grade["student_token"]is 64 hex chars and the rawstudent_idnever appears in any returned dict.
Troubleshooting
- Exact search hangs on a large group. explodes for wide groups (a 40-item group dropping 5 is 658,008 subsets). Cap
Nandn, use the greedy path when allpossiblevalues match, and only fall back to exact when the divergence flag matters for an appeal. - The dropped assignment differs from the LMS. Your target uses greedy and you ran exact (or vice versa). Match its behavior explicitly — Canvas drops greedily by fraction — and reconcile against it as shown in Reconstructing Canvas Final Grades in Python.
- A protected assignment got dropped. The exemption list was empty or held the wrong ids. Populate
never_dropfrom the group’srules.never_droparray and confirm the partition removes those ids fromdroppablebefore either strategy runs. - Grade rises when you drop nothing. An ungraded or excused item leaked into
scoresas a zero and is being treated as a drop candidate. Remove null and excused work upstream so the drop pool contains only genuinely scored members. - Tie between two equally-bad drops changes run to run. Non-deterministic ordering. Break ties on
assignment_id(the greedy sort key already does) so repeated runs over identical evidence produce identical drops. - Weighted total ignores the drop entirely. You combined pre-drop fractions in the outer mean. Feed
resolve_group’s surviving fraction — not the raw group average — into , matching Weighted Grade Calculation Engines.
Related
- Weighted Grade Calculation Engines — the parent engine whose weighted-mean arithmetic this drop logic plugs into.
- Reconstructing Canvas Final Grades in Python — rebuilds the full course total that these post-drop group fractions feed.
Part of: Weighted Grade Calculation Engines