Python data capstone: audit a CSV and write a defensible summary
Combine CSV reading, validation, grouping, and statistics in a complete small project with a reconciliation check.
Learning lesson · Reviewed October 2, 2026. Allow additional time for the practice. Prerequisites: all earlier Python & data lessons. Python 3; standard library only. This is a small-data teaching project.
- Run a complete analysis on a reproducible fictional CSV.
- Report accepted and rejected counts alongside a summary.
- State what the result cannot establish.
Your task is to summarize fictional assessment submissions by course. Each record represents one submission, not necessarily one unique learner. Scores must be whole numbers from 0 to 100. A blank score is missing, and every rejected submission must retain a reason. The project succeeds when its result can be traced back to the input and its limitations are clear.
Begin with an inspectable dataset
The embedded CSV lets you run the entire example without creating a file or installing packages. It is deliberately tiny so you can verify the result by hand. There are six records: four valid scores, one missing score, and one score above the allowed range.
import csv
import io
from statistics import mean, median
csv_text = """submission,course,score
S01,Python,80
S02,Python,0
S03,Data,70
S04,Data,
S05,Data,90
S06,Python,105
"""
def parse_score(text):
cleaned = text.strip()
if cleaned == "":
return None, "missing"
try:
score = int(cleaned)
except ValueError:
return None, "not an integer"
if score < 0 or score > 100:
return None, "outside 0-100"
return score, None
rows = list(csv.DictReader(io.StringIO(csv_text)))
groups = {}
rejected = []
for row_number, row in enumerate(rows, start=2):
score, reason = parse_score(row["score"])
if reason is not None:
rejected.append((row_number, reason))
continue
course = row["course"]
if course not in groups:
groups[course] = []
groups[course].append(score)
accepted_count = sum(len(values) for values in groups.values())
assert accepted_count + len(rejected) == len(rows)
print("Input:", len(rows), "Accepted:", accepted_count,
"Rejected:", len(rejected))
for course, values in sorted(groups.items()):
print(f"{course}: n={len(values)}, mean={mean(values):.1f}, "
f"median={median(values):.1f}")
print("Rejected:", rejected)
# Input: 6 Accepted: 4 Rejected: 2
# Data: n=2, mean=80.0, median=80.0
# Python: n=2, mean=40.0, median=40.0
# Rejected: [(5, 'missing'), (7, 'outside 0-100')]CSV fields arrive as text, so the parser runs before statistics. The rejection numbers correspond to lines in this simple example with one header line and no multiline fields. For general CSV files, a record number and a physical file line number can differ. The code creates a group only after accepting a score, avoiding an empty list passed into a summary function.
| Course | All submissions | Accepted scores | Rejected | Mean / median of accepted |
|---|---|---|---|---|
| Data | 3 | 70, 90 | 1 missing | 80.0 / 80.0 |
| Python | 3 | 80, 0 | 1 out of range | 40.0 / 40.0 |
Audit the result manually
Data has two valid scores: (70 + 90) / 2 = 80. Python has two: (80 + 0) / 2 = 40. For two observations, each median is also their midpoint. The two rejections explain the gap between six source records and four accepted values. Keeping zero is essential: silently dropping it would change the Python mean to 80.
6 records→Validate
4 accepted + 2 rejected→Group
2 courses→Explain
Counts + limitations
Write the report with its scope attached
A defensible summary is: “In this fictional six-submission sample, four scores met the validation rules. The mean among accepted scores was 80 for Data and 40 for Python, with two observations in each group. One submission was missing a score and one exceeded the permitted range. These results describe accepted submissions only; they do not establish course quality or represent all learners.”
That wording links the outcome to the sample, rule, and denominator. A headline such as “Data learners perform twice as well” would add a claim the project did not investigate. The assessments may differ, rejected values may not be random, and repeated learners are not checked.
Adapt to a file carefully
For your own small CSV, replace the embedded reader with the pattern below. Confirm column names, expected encoding, delimiter, and row structure first. This project assumes the documented schema; it is not a general-purpose ingestion system. For broader input, add checks for missing or extra columns, blank course names, duplicate submission IDs, and unexpectedly large files.
# Replace the embedded reader with this for a compatible local file:
with open("scores.csv", encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))Practice: add a valid boundary score
Add S07,Python,100 to the embedded CSV. Predict the new input, accepted, and rejected counts, then calculate the Python mean and median. Keep the Data result unchanged. Explain why an unchanged rejection count is still worth reporting.
Show the worked solution
There are seven inputs, five accepted scores, and two rejected records. Python now has [80, 0, 100]: its mean is 60 and its median is 80. Data remains at 80 for both summaries. The missing and invalid records still limit interpretation even though another accepted observation was added.
Completion checklist
Save the input, validation policy, code, output, and a short limitations paragraph together. Run the boundary checks from the previous lesson when you change the parser. You have completed a small reproducible workflow; the next step is practicing on a dataset whose schema and provenance you can explain.
Official sources checked October 2, 2026. Examples, exercises, and rubrics are original Noetrion teaching material; fictional datasets are identified below.