Course outline · 0% complete

0/32 lessons0%

Course overview →

Project: Gradebook

lesson 10-3 · ~13 min · 32/32

The report

A gradebook maps each student to a list of scores, a dict of lists, the first time you have nested two containers:

students = {
    "Ada": [90, 95, 88],
    "Grace": [72, 85, 80],
    "Alan": [60, 75, 70],
}

The program prints one line per student with their average and letter grade, reusing average() and letter() exactly as you wrote them in lesson 8-3. That is decomposition paying off: the hard thinking is already done and named.

students: a dict whose VALUES are lists "Ada" [90, 95, 88] "Grace" [72, 85, 80] "Alan" [60, 75, 70] average() 91.0 -> A 79.0 -> C 68.3 -> F The items() loop unpacks one arrow per pass: name gets the key, scores gets the WHOLE list. The class average needs all nine scores, so it takes a nested loop, not the three averages.
The gradebook as a dict of lists. Each student name on the left points to an entire list of scores rather than a single value, and one pass of the items loop unpacks one of those arrows so that the name and the whole list arrive together. Each list then flows through average and letter to produce the per-student line, and because a class average must weigh all nine individual scores it needs a nested loop instead of averaging the three results.

The per-student report

The data is a dict of lists, and the two functions from lesson 8-3 are reused without modification.

students = {
    "Ada": [90, 95, 88],
    "Grace": [72, 85, 80],
    "Alan": [60, 75, 70],
}

def average(nums):
    return sum(nums) / len(nums)

def letter(avg):
    if avg >= 90:
        return "A"
    if avg >= 80:
        return "B"
    if avg >= 70:
        return "C"
    return "F"

for name, scores in students.items():
    avg = average(scores)
    print(f"{name}: {avg:.1f} {letter(avg)}")

Output

Ada: 91.0 A
Grace: 79.0 C
Alan: 68.3 F

The loop unpacks each entry into a name and a whole list of scores, then passes that list straight to average, which needs no knowledge of gradebooks at all. Storing the result in avg means it is computed once and used twice, for the formatted number and for the letter.

Grace's line is the interesting one. An average of 79.0 misses the 80 threshold by a single point and lands in C, which is a good reminder that the bands are decided by letter alone and that the display rounding never affects the grade.

On the first pass of for name, scores in students.items():, the value of scores is the list [90, 95, 88].

The items() view yields (key, value) pairs and the loop unpacks them, so name receives the key "Ada" and scores receives that key's entire value.

The point worth absorbing is that a dict value can be any object at all, including another container. Nesting a list inside a dict like this is how a single name comes to own a whole collection of scores, and the same idea extends further: a dict of dicts is how most JSON from a web service is shaped.

The two summary lines

The finale needs a nested loop for the class average and a find-the-max scan for the top student.

students = {
    "Ada": [90, 95, 88],
    "Grace": [72, 85, 80],
    "Alan": [60, 75, 70],
}

def average(nums):
    return sum(nums) / len(nums)

total = 0
count = 0
for scores in students.values():
    for s in scores:
        total += s
        count += 1
print(f"class average: {total / count:.1f}")

best_name = None
best_avg = 0
for name, scores in students.items():
    if average(scores) > best_avg:
        best_avg = average(scores)
        best_name = name
print(f"top student: {best_name}")

Output

class average: 79.4
top student: Ada

The nested loop is what makes the class average correct. The outer loop visits each student's list and the inner one visits each score inside it, so all nine scores contribute equally to a single total. Averaging the three per-student averages would give a different answer whenever students have different numbers of scores.

The second scan tracks a name and a number together, exactly as the word counter did. Note that average(scores) is called twice on the winning pass, once to compare and once to store, which is harmless here but would be worth assigning to a variable if the computation were expensive.

Where you stand

Count what these three projects used: input and conversion (unit 3), elif chains and truthiness (unit 4), while/break and the four loop patterns (unit 5), lists (unit 6), dicts, tuples, and sets (unit 7), functions and decomposition (unit 8), and the counting and find-the-max idioms throughout. That is the working core of Python.

From here, good next steps on the platform: keep the patterns sharp on the DSA problems (start with the easy array and string ones, they are these loop patterns in disguise), and when a program crashes, read the traceback bottom-up like you practiced in lesson 9-1 before touching the code.

A fourth student with "Linus": [88, 92] receives an A.

The average is (88 + 92) / 2, which is exactly 90.0, and the first band tests avg >= 90. Since 90.0 >= 90 is True, the chain returns A and stops there.

Boundary values are precisely where >= against > bugs hide. Written as avg > 90 the same student would fall through to the B branch, and nothing in the output of the other three students would reveal the mistake. Testing the values that sit exactly on each threshold is the habit that catches this class of bug, and it is the same point made about the grading chain back in lesson 4-1.