AXLR / Catalog / education

grade_stats

Class grade statistics (mean, median, population and sample SD, distribution by bands or a histogram), grade curves (linear rescaling to a target mean and SD, square-root curve, top-score scaling) and classical item analysis of a students x items 0/1 matrix: difficulty p, point-biserial and corrected item-rest correlations, upper-lower 27 % discrimination D, and KR-20 and KR-21 reliability. Exact fractions for means and variances.

Install

/tools install grade_stats
/tools install grade_stats@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · grade_stats 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool grade_stats; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
operationstringyesone of "stats", "curve", "item_analysis"
scoresarray of ['number', 'string']noitems ≥ 1
max_scorenumber | stringno100
bandsarray of objectno
methodstringnoone of "linear", "sqrt", "top_score"
target_meannumber | stringno
target_sdnumber | stringnoOmitted: shift to target_mean only
clipbooleannotrue
matrixarray of arraynoitems ≥ 2
group_fractionnumber | stringno0.27
variancestringno"population"Total-score variance in KR-20/KR-21. one of "population", "sample"
decimalsintegerno2≥ 0; ≤ 10
Input schema (JSON)
{
  "type": "object",
  "required": [
    "operation"
  ],
  "additionalProperties": false,
  "properties": {
    "operation": {
      "type": "string",
      "enum": [
        "stats",
        "curve",
        "item_analysis"
      ]
    },
    "scores": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": [
          "number",
          "string"
        ]
      }
    },
    "max_score": {
      "type": [
        "number",
        "string"
      ],
      "default": 100
    },
    "bands": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "min",
          "label"
        ],
        "properties": {
          "min": {
            "type": [
              "number",
              "string"
            ]
          },
          "label": {
            "type": "string"
          }
        }
      }
    },
    "method": {
      "type": "string",
      "enum": [
        "linear",
        "sqrt",
        "top_score"
      ]
    },
    "target_mean": {
      "type": [
        "number",
        "string"
      ]
    },
    "target_sd": {
      "type": [
        "number",
        "string"
      ],
      "description": "Omitted: shift to target_mean only"
    },
    "clip": {
      "type": "boolean",
      "default": true
    },
    "matrix": {
      "type": "array",
      "minItems": 2,
      "items": {
        "type": "array",
        "minItems": 2,
        "items": {
          "type": "integer",
          "enum": [
            0,
            1
          ]
        }
      }
    },
    "group_fraction": {
      "type": [
        "number",
        "string"
      ],
      "default": 0.27
    },
    "variance": {
      "type": "string",
      "enum": [
        "population",
        "sample"
      ],
      "default": "population",
      "description": "Total-score variance in KR-20/KR-21"
    },
    "decimals": {
      "type": "integer",
      "minimum": 0,
      "maximum": 10,
      "default": 2
    }
  }
}

Example

Input

{
  "operation": "item_analysis",
  "matrix": [
    [
      1,
      1,
      1
    ],
    [
      1,
      1,
      0
    ],
    [
      1,
      0,
      1
    ],
    [
      0,
      1,
      0
    ],
    [
      1,
      0,
      0
    ],
    [
      0,
      0,
      0
    ]
  ]
}

Output (stdout)

{
  "kr20": "0.3636",
  "kr20_exact": "4/11",
  "sum_pq_exact": "25/36",
  "total_variance_exact": "11/12"
}

Files

The command the install card registers is python3 grade_stats.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

grade_stats.py · sha256 6fccb5ef10a4db8bacb4f0d394cf15fb782c6ce869ef33b9f22294139c17103f
#!/usr/bin/env python3
"""Class grade statistics, grade curves and classical item analysis.

Input (JSON on stdin):
  operation  "stats" | "curve" | "item_analysis"
  scores     [numbers]                         stats, curve
  max_score  default 100                       stats (histogram), curve
  bands      [{"min": 90, "label": "A"}, ...]  stats: distribution by band (else 10-point bins)
  method     "linear" | "sqrt" | "top_score"   curve
  target_mean, target_sd                       curve linear (target_sd optional: shift only)
  clip       default true: curved scores kept within 0..max_score
  matrix     [[0/1 per item] per student]      item_analysis
  group_fraction  default 0.27 (upper-lower groups)
  variance   "population" | "sample"           SD in KR-20 / KR-21, default population
  decimals   default 2 (4 for item statistics)

Means, medians and variances are exact fractions; square roots in 50-digit decimals.
"""
import json
import re
import sys
from decimal import ROUND_HALF_UP, Decimal, getcontext
from fractions import Fraction

getcontext().prec = 50
DECIMAL = re.compile(r"\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d{1,3})?\s*")


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def number(value, field):
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        fail(f"{field} must be a number or a decimal string")
    if isinstance(value, str) and not DECIMAL.fullmatch(value):
        fail(f"{field} must be a number or a decimal string")
    try:
        result = Fraction(str(value).strip())
    except (ValueError, ZeroDivisionError):
        fail(f"{field} must be a number or a decimal string")
    if abs(result) > 10 ** 12:
        fail(f"{field} is too large")
    return result


def dec(value):
    return Decimal(value.numerator) / Decimal(value.denominator) if isinstance(value, Fraction) else value


def text(value, places):
    if value is None:
        return None
    return str(dec(value).quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP))


def sqrt(value):
    return dec(value).sqrt()


def mean(xs):
    return sum(xs, Fraction(0)) / len(xs)


def variance(xs, sample=False):
    m = mean(xs)
    ss = sum(((x - m) ** 2 for x in xs), Fraction(0))
    if sample:
        return ss / (len(xs) - 1) if len(xs) > 1 else None
    return ss / len(xs)


def median(xs):
    s = sorted(xs)
    n = len(s)
    return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2


def exact(value):
    return str(value) if value is not None else None


def summary(xs, places):
    vp, vs = variance(xs), variance(xs, sample=True)
    return {"n": len(xs), "mean": text(mean(xs), places), "mean_exact": exact(mean(xs)),
            "median": text(median(xs), places), "min": text(min(xs), places), "max": text(max(xs), places),
            "sd_population": text(sqrt(vp), places + 2), "sd_sample": text(sqrt(vs), places + 2) if vs is not None else None,
            "variance_population_exact": exact(vp), "variance_sample_exact": exact(vs)}


def read_scores(request):
    scores = request.get("scores")
    if not isinstance(scores, list) or not scores or len(scores) > 100000:
        fail("scores must be a non-empty list of numbers")
    return [number(s, f"scores[{i}]") for i, s in enumerate(scores)]


def stats(request, places):
    xs = read_scores(request)
    out = summary(xs, places)
    top = number(request.get("max_score", 100), "max_score")
    bands = request.get("bands")
    if bands is not None:
        if not isinstance(bands, list) or not bands:
            fail("bands must be a list of {min, label}")
        rows = []
        for i, b in enumerate(bands):
            if not isinstance(b, dict) or set(b) != {"min", "label"} or not isinstance(b["label"], str):
                fail(f"bands[{i}] must be {{min, label}}")
            rows.append((number(b["min"], f"bands[{i}].min"), b["label"]))
        rows.sort(key=lambda r: r[0], reverse=True)
        counts = {label: 0 for _, label in rows}
        below = 0
        for x in xs:
            band = next((label for low, label in rows if x >= low), None)
            if band is None:
                below += 1
            else:
                counts[band] += 1
        out["distribution"] = [{"label": label, "min": text(low, places), "count": counts[label],
                                "percent": text(Fraction(counts[label] * 100, len(xs)), 1)} for low, label in rows]
        if below:
            out["below_lowest_band"] = below
    else:
        if top <= 0:
            fail("max_score must be positive")
        width = top / 10
        bins = []
        for k in range(10):
            low, high = width * k, width * (k + 1)
            count = sum(1 for x in xs if low <= x < high or (k == 9 and x == high))
            bins.append({"from": text(low, places), "to": text(high, places), "count": count})
        out["histogram"] = bins
        out["outside_0_to_max"] = sum(1 for x in xs if x < 0 or x > top)
    return out


def curve(request, places):
    xs = read_scores(request)
    top = number(request.get("max_score", 100), "max_score")
    if top <= 0:
        fail("max_score must be positive")
    clip = request.get("clip", True)
    if not isinstance(clip, bool):
        fail("clip must be true or false")
    method = request.get("method")
    if method == "linear":
        if request.get("target_mean") is None:
            fail("linear curve needs target_mean (and optionally target_sd)")
        target_mean = dec(number(request["target_mean"], "target_mean"))
        m, sd = dec(mean(xs)), sqrt(variance(xs))
        if request.get("target_sd") is not None:
            target_sd = dec(number(request["target_sd"], "target_sd"))
            if target_sd < 0:
                fail("target_sd must not be negative")
            if sd == 0:
                fail("all scores are equal: the SD cannot be rescaled")
            ratio = target_sd / sd
        else:
            ratio = Decimal(1)
        new = [target_mean + (dec(x) - m) * ratio for x in xs]
        rule = "new = target_mean + (x - mean) x target_sd / sd (population SD)"
    elif method == "sqrt":
        if any(x < 0 for x in xs):
            fail("the square-root curve needs scores of zero or more")
        new = [sqrt(x / top) * dec(top) for x in xs]
        rule = "new = sqrt(x / max_score) x max_score (10 x sqrt(x) on 100)"
    elif method == "top_score":
        best = max(xs)
        if best <= 0:
            fail("the top score must be positive")
        new = [dec(x * top / best) for x in xs]
        rule = "new = x x max_score / top score"
    else:
        fail("method must be linear, sqrt or top_score")
    clipped = 0
    if clip:
        bounded = [min(max(v, Decimal(0)), dec(top)) for v in new]
        clipped = sum(1 for a, b in zip(new, bounded) if a != b)
        new = bounded
    rounded = [Fraction(text(v, places)) for v in new]
    return {"method": method, "rule": rule, "curved": [text(v, places) for v in new],
            "clipped": clipped, "before": summary(xs, places), "after": summary(rounded, places)}


def pearson(xs, ys):
    mx, my = mean(xs), mean(ys)
    sxy = sum(((x - mx) * (y - my) for x, y in zip(xs, ys)), Fraction(0))
    sxx = sum(((x - mx) ** 2 for x in xs), Fraction(0))
    syy = sum(((y - my) ** 2 for y in ys), Fraction(0))
    if sxx == 0 or syy == 0:
        return None
    r = sqrt(sxy * sxy / (sxx * syy))
    return r if sxy >= 0 else -r


def item_analysis(request, places):
    matrix = request.get("matrix")
    if not isinstance(matrix, list) or len(matrix) < 2:
        fail("matrix must be a list of at least 2 students, each a list of 0/1 item scores")
    k = len(matrix[0]) if isinstance(matrix[0], list) else 0
    if k < 2:
        fail("matrix rows must have at least 2 items")
    for i, row in enumerate(matrix):
        if not isinstance(row, list) or len(row) != k or any(v not in (0, 1) or isinstance(v, bool) for v in row):
            fail(f"matrix[{i}] must be a list of {k} values, each 0 or 1")
    sample = request.get("variance", "population")
    if sample not in ("population", "sample"):
        fail("variance must be population or sample")
    fraction = number(request.get("group_fraction", "0.27"), "group_fraction")
    if not 0 < fraction <= Fraction(1, 2):
        fail("group_fraction must be above 0 and at most 0.5")
    n = len(matrix)
    totals = [Fraction(sum(row)) for row in matrix]
    var_t = variance(totals, sample == "sample")
    g = int(dec(fraction * n).quantize(Decimal(1), rounding=ROUND_HALF_UP))
    g = min(max(g, 1), n // 2)
    order = sorted(range(n), key=lambda i: (-totals[i], i))
    upper, lower = order[:g], order[-g:]
    items, sum_pq = [], Fraction(0)
    p4 = 4
    for j in range(k):
        col = [Fraction(row[j]) for row in matrix]
        p = mean(col)
        sum_pq += p * (1 - p)
        rest = [t - c for t, c in zip(totals, col)]
        pu = Fraction(sum(matrix[i][j] for i in upper), g)
        pl = Fraction(sum(matrix[i][j] for i in lower), g)
        r_pb, r_rest = pearson(col, totals), pearson(col, rest)
        flags = []
        if p < Fraction(1, 5):
            flags.append("very hard (p < 0.20)")
        if p > Fraction(9, 10):
            flags.append("very easy (p > 0.90)")
        if pu - pl < Fraction(1, 5):
            flags.append("low discrimination (D < 0.20)")
        if r_rest is not None and r_rest < 0:
            flags.append("negative item-rest correlation")
        items.append({"item": j + 1, "difficulty_p": text(p, p4), "p_exact": exact(p),
                      "point_biserial": text(r_pb, p4), "point_biserial_corrected": text(r_rest, p4),
                      "upper_p": text(pu, p4), "lower_p": text(pl, p4), "discrimination_d": text(pu - pl, p4),
                      "d_exact": exact(pu - pl), "flags": flags})
    out = {"students": n, "items": k, "totals": summary(totals, places), "item_statistics": items,
           "groups": {"fraction": exact(fraction), "size": g, "upper": [i + 1 for i in upper],
                      "lower": [i + 1 for i in lower]},
           "variance_used": sample, "sum_pq_exact": exact(sum_pq)}
    if var_t:
        kr20 = Fraction(k, k - 1) * (1 - sum_pq / var_t)
        m = mean(totals)
        kr21 = Fraction(k, k - 1) * (1 - m * (k - m) / (k * var_t))
        out.update({"kr20": text(kr20, p4), "kr20_exact": exact(kr20), "kr21": text(kr21, p4), "kr21_exact": exact(kr21),
                    "total_variance_exact": exact(var_t)})
    else:
        out.update({"kr20": None, "kr21": None, "note": "total scores have no variance: reliability undefined"})
    return out


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    extra = sorted(set(request) - {"operation", "scores", "max_score", "bands", "method", "target_mean", "target_sd",
                                   "clip", "matrix", "group_fraction", "variance", "decimals"})
    if extra:
        fail(f"unknown field(s): {', '.join(extra)}")
    places = request.get("decimals", 2)
    if isinstance(places, bool) or not isinstance(places, int) or not 0 <= places <= 10:
        fail("decimals must be an integer from 0 to 10")
    operation = request.get("operation")
    handlers = {"stats": stats, "curve": curve, "item_analysis": item_analysis}
    if not isinstance(operation, str) or operation not in handlers:
        fail("operation must be stats, curve or item_analysis")
    result = {"operation": operation}
    result.update(handlers[operation](request, places))
    print(json.dumps(result))


if __name__ == "__main__":
    try:
        main()
    except SystemExit:
        raise
    except Exception as error:  # last resort: a usage error, never a traceback
        fail(f"invalid input ({type(error).__name__})")

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/grade_stats/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/grade_stats/1.0.0/grade_stats.py