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 DD of assignments is:

f(D)=iDeiiDmif(D) = \frac{\sum_{i \notin D} e_i}{\sum_{i \notin D} m_i}

The goal is to choose the DD with D=N|D| = N that maximizes f(D)f(D) — 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 (nN)\binom{n}{N} 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 ei/mie_i / m_i ascending and drop the first NN. This is fast (O(nlogn)O(n \log n)) 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 (nN)\binom{n}{N} drop sets, honor the exemptions, and keep the set that yields the largest f(D)f(D). 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.

Greedy versus exact drop-lowest resolution inside a weighted group A decision diagram contrasting two strategies for dropping the lowest N scores in a group. On the left, a greedy strategy sorts assignments by their per-assignment fraction and removes the N lowest; it is exact only when all assignments share the same possible points. On the right, an exact strategy enumerates every N-sized drop subset, skips exempt assignments, and keeps the subset that maximizes the group fraction; it is the ground truth when point values differ. Both feed a comparison node that flags when the greedy grade is lower than the exact grade, which is when the student would be undercharged by the shortcut. The surviving group fraction then flows into the weighted mean across all groups. Drop lowest N inside one weighted group GREEDY · O(n log n) sort by e_i / m_i ascending drop the first N droppable exact when all m_i are equal can undercharge when points differ what most gradebooks ship EXACT · C(n, N) subsets enumerate every N-drop set skip never_drop members keep argmax f(D) ground truth for appeals cheap while C(n,N) stays small compare · flag if greedy < exact surviving group fraction e_g / m_g feeds Σ w_g (e_g/m_g) ÷ Σ w_g

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 ei/mie_i / m_i ascending and remove the first NN; 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 NN-sized subset of droppable members, evaluate f(D)f(D) 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 GfinalG_{\text{final}} arithmetic.

Complete runnable code block

python
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 yields 13/20 = 0.65, while exact also compares dropping assignment 1 — verify greedy_undercharges reports which subset actually maximizes f(D)f(D) for your data.
  • Equal points collapse the gap. Reset every possible to the same value; then greedy_fraction == exact_fraction for all N, confirming the greedy shortcut is exact under equal weighting.
  • Exemptions are never dropped. Add the group’s highest-stakes assignment to never_drop and assert it appears in the kept set of both strategies regardless of its fraction.
  • N is clamped. Set drop_lowest above the number of droppable members and confirm dropped_count equals 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 raw student_id never appears in any returned dict.

Troubleshooting

  • Exact search hangs on a large group. (nN)\binom{n}{N} explodes for wide groups (a 40-item group dropping 5 is 658,008 subsets). Cap N and n, use the greedy path when all possible values 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_drop from the group’s rules.never_drop array and confirm the partition removes those ids from droppable before either strategy runs.
  • Grade rises when you drop nothing. An ungraded or excused item leaked into scores as 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 wg(eg/mg)\sum w_g (e_g/m_g), matching Weighted Grade Calculation Engines.

Part of: Weighted Grade Calculation Engines