User guide
Assignments
The dataset decides what can be checked. Each assignment gives the grader a different situation, and the rubric is designed around it.
The Donut Effect. No answer key, so each step is checked against the student's own data.
Assignment 2Exploratory Visualization. No data handed in, so the required elements are graded from saved outputs.
Assignment 3Evictions and the NDVI. One shared zip, so there is an answer key for the first time.
Assignment 1: The Donut Effect#
rubrics/hw1.yamlassignments/hw1.pydata required
Students download their own Zillow ZHVI extract, and Zillow revises and extends that file every month. Row counts, ZIP counts and final percentages therefore differ legitimately between submissions, so the grader never compares against a stored answer. It asks whether each step in the instructions worked, and checks it against the student's own data:
| Instruction | How it is checked |
|---|---|
| Load the data | A pandas import, a read_* call, a relative path, and a wide frame with many month columns |
| Trim to Philadelphia | Every ZIP in the subset is a Philadelphia ZIP, and City/State hold no other place |
| Melt to tidy | Row count equals this student's own unique ZIPs × unique dates, with values in a column named ZHVI |
| Split Center City | The two frames partition this student's own Philadelphia ZIP set, using the template's ZIP list |
| Percent increase function | Called on a frame the grader builds, so the right answer is known whatever data was loaded. See Hidden tests. |
| Compare the two groups | Both averages exist, are plausible, and point in the Donut Effect direction |
The only fixed values in the rubric are the ones the assignment itself fixes: the Center City ZIP list printed in the student template, and the two comparison dates (2020-03-31 and 2022-03-31). A regression test confirms this: the same notebook graded against a shorter, smaller, rescaled ZHVI file still scores 100/100.
| Item | Type | Points |
|---|---|---|
| Notebook executes successfully | execution | 10 |
| Data loaded correctly | structural | 10 |
| Data trimmed to Philadelphia | deterministic | 15 |
| Data melted into tidy format | deterministic | 20 |
| Center City split correctly | deterministic | 15 |
| Percent increase function works | hidden_test | 20 |
| Center City vs. rest of Philadelphia compared | deterministic | 10 |
| Total | 100 |
Assignment 2: Exploratory Visualization#
rubrics/hw2.yamlassignments/hw2.pyno data
Students pick their own dataset and hand in only the notebook. The grader has no copy of the data, so it cannot re-run anything: every submission dies at read_csv. The content of a chart was never checkable, and this assignment doesn't ask for it. It asks whether the student can produce the required elements. So the rubric splits the assignment in two.
Facts, graded automatically (70 points)#
An Altair chart compiles to a Vega-Lite specification, and that specification is stored in the notebook's own saved output. The grader reads mean/count/bin transformations, interval brushes and transform_filter() cross-filters out of the spec instead of guessing from the source text. That is how it tells apart the three things students most often confuse:
| Student wrote | Vega-Lite says | Verdict |
|---|---|---|
alt.selection_interval() + add_params | params: [{select: {type: interval}}] | brush ✓ |
.interactive() | the same, plus bind: "scales" | pan/zoom, not a brush |
alt.selection_point() | select: {type: point} | a selection, half credit |
transform_filter("datum.x > 5") | filter with no param | a subset, not a transformation |
transform_filter(brush) across hconcat | filter: {param: …} + two views | cross-filter ✓ extra credit |
Judgement, graded by a person (30 points)#
The assignment grades the matplotlib chart on "colour choices and clarity" and asks for written reasoning under each chart. The quality of a picture and the substance of a paragraph can't be checked by a machine. Those items always come back as manual_review, whatever they score. Each carries a provisional score built from real signals (labelling and colour calls extracted from the source, word counts, the position of each markdown cell) plus the text itself. A TA confirms or adjusts a number instead of starting from a blank box.
The grader records, per chart, where the evidence came from. If an instructor does supply the dataset, a freshly executed chart replaces the saved one automatically and confidence rises from 0.85 to 0.95.
| Item | Type | Points |
|---|---|---|
| Notebook was run and submitted with its outputs | execution | 10 |
| Dataset loaded from a relative path | structural | 10 |
| Matplotlib chart present and rendered | deterministic | 10 |
| Reasoning for using matplotlib | qualitative | 5 |
| Matplotlib chart aesthetics (colour and clarity) | qualitative | 5 |
| Seaborn chart present and rendered | deterministic | 10 |
| Motivation for the seaborn plot type | qualitative | 5 |
| Three Altair charts | deterministic | 15 |
| Altair transformation used | deterministic | 8 |
| Brush selection used | deterministic | 7 |
| Written conclusion below each chart | qualitative | 15 |
| Extra credit: cross-filtered two-chart Altair dashboard | deterministic | 0 |
| Total | 100 |
The extra-credit dashboard is worth 0 points in the rubric so that it never inflates the maximum. When it is found, the item is flagged with the suggested bonus. A manual override on it adds to the student's total without changing what they are graded out of.
Assignment 3: Evictions and the NDVI#
rubrics/hw3.yamlassignments/hw3.pydata optional
Assignment 3 hands out assignment3-data.zip, and everyone works from it. That one difference changes the whole design: for the first time the right answers exist, so the grader checks results rather than inferring intent.
| Fact about the shipped data | Value |
|---|---|
Philadelphia census tracts in PA-tracts.geojson | 384 |
Rows of li_violations.csv in the fifteen listed types (of 434,052) | 34,108 |
| Rows in the tidy eviction frame | 5,376 |
| Evictions in that frame | 149,472 |
| Median NDVI inside the city limits | 0.2025 |
| Median NDVI in the suburbs | 0.3748 |
| Street trees sampled / their median NDVI | 2,480 / 0.178 |
Every value was recomputed under the variations students legitimately produce (crop=True and crop=False, masked and filled arrays, sjoin(how="inner") and how="left") before it was written into the rubric. Where a step has two defensible answers, both are listed rather than one being called wrong. For example, the optional unstack(fill_value=0).stack() fill gives 5,535 groups instead of 4,000.
Because the answers are fixed, a matched expected value scores confidence 1.0. Grading combines two readings, the saved outputs and the re-run, so the data is optional:
| The correct demo submission scores | |
|---|---|
| With all five data files supplied | 99.5 / 100 |
| With no data supplied at all | 99.5 / 100 |
Supply the data and 19 of the 22 items come back confirmed by both readings. Leave it out and the grade comes from the submitted notebook alone, at one confidence step lower (0.9 rather than 1.0).
Plots are graded on the objects#
Two thirds of Part 1 uses hvplot, and an hvplot call returns a holoviews object that knows what it is:
| The instructions ask for | What the object says | Verdict |
|---|---|---|
groupby="year", dynamic=False | HoloMap, kdims [year], 14 frames of Polygons | widget choropleth ✓ |
The same, dynamic=False omitted | DynamicMap, 0 frames | nothing renders. Named, 60% credit |
| One map per violation type | HoloMap, kdims [violationdescription], 15 frames | ✓ |
| Two maps side by side | Layout, shape (1, 2), children [Polygons, Polygons] | ✓ |
| The same, panels never trimmed | Layout whose children are HoloMap | still carries its widget. 75% |
| The yearly eviction trend | Curve, 14 points summing to 149,472 | graded on the numbers plotted |
Named mistakes, not just wrong answers#
| What the student did | How it is recognised | Result |
|---|---|---|
points_from_xy(lat, lng) | The data spans 39.8–40.1 east, not −75.2 | Coordinate swap, 30% |
| Joined before trimming | The join has 434,052 rows, not 34,108 | Right step, wrong order: 70% |
| Joined across two CRSs | The join lost rows, and none should be lost | Named, 50% |
| NDVI from bands 3 and 4 | Medians come back at −0.02, not 0.20 | Off-by-one band index, 50% |
np.median on a masked array | The medians are NaN | The NaN trap, 40% |
| Suburbs = the envelope | Its area is city + suburbs | difference() never applied, 50% |
| Tracts left in EPSG:4326 | The polygon's area is 0.037, not 369 km² | Degrees vs metres, 40% |
Where the 100 points go#
| Item | Points | |
|---|---|---|
| Notebook executes | 10 | |
| 1.1.1 | Eviction data loaded with geopandas | 3 |
| 1.1.2 | Trimmed to Philadelphia (384 tracts) | 5 |
| 1.1.3 | Melted to tidy format (5,376 rows) | 6 |
| 1.1.4 | Yearly eviction trend with hvplot | 5 |
| 1.1.5 | Year-by-year choropleth with a widget | 5 |
| 1.2.1 | Violations loaded as a point GeoDataFrame | 4 |
| 1.2.2 | Trimmed to the fifteen types (34,108 rows) | 4 |
| 1.2.3 | Hex bin map with tracts overlaid | 4 |
| 1.2.4 | Spatial join to census tracts | 5 |
| 1.2.5 | Counted per type and tract | 5 |
| 1.2.6 | Merged back onto tract geometries | 3 |
| 1.2.7 | Choropleth per violation type with a widget | 5 |
| 1.3 | Evictions and violations side by side | 4 |
| 2.1.1 | Landsat scene opened with rasterio | 3 |
| 2.1.2 | City and suburb polygons | 5 |
| 2.1.3 | Masked, and the NDVI computed | 6 |
| 2.1.4 | Median NDVI compared | 4 |
| 2.2.1 | Street tree data loaded | 3 |
| 2.2.2 | NDVI sampled at the tree locations | 4 |
| 2.2.3 | Histogram and mapped tree points | 4 |
| 2.2.3 | Figure styling (judged by a person) | 3 |
| 1.4 | Extra credit (0 points, flagged, suggested 5) | 0 |
| Total | 100 |
Execution is 10 points, Part 1 is 58 across thirteen steps, and Part 2 is 32 across eight. Within each part, the weight follows how much the step matters: the melt, the spatial join and the NDVI masking are where the assignment's teaching happens, while the loading steps are hard to get wrong and cheap to fix.
Assignments 4–6#
Their student templates are in assignment_template/, but they are not graded yet. Each needs a rubric YAML and a grader class. See Adding an assignment.