Lists and tuples in Python — practice with class statistics, a student finder and moving averages (and the minimum grade that was a zero nobody had got)

I was computing the statistics for my class: the average, the highest grade, the lowest. Everything came out right… except the minimum. The program told me the worst grade was a 0. I looked through the list from top to bottom: nobody had got a 0, the worst was a 3.5. There was no error on the screen, just a number that did not exist in my data.

What I had done was start the search for the minimum with minimum = 0, “just to put something”. The program never found a grade lower than 0 (of course), so it kept my invented value. That is the kind of bug that is fixed with one line and costs an afternoon to find if you do not know where to look.

Today we practise the sequence patterns with three complete programs: class statistics, a student finder with slices and an analysis of the grades of a term. This practice builds on the lists and tuples theory: indexes, slices, unpacking and the traversal patterns. If you feel unsure about functions, review functions in Python first.

How to use this article

Each program is complete in a single file. Copy it, save it with the name shown and launch it from the terminal with python3 name.py. Before looking at the output, try to predict it: it is the best way to know whether you have understood the code.

No program uses input(). The data is fixed in the code so that your output is identical to the one you see here.

Program 1 · Class statistics

We have the students as a list of tuples (name, grade), and we want the average, the best and the worst grade, and how many have passed. Each result is a different pattern: accumulator, two extreme searches and a counter.

class_stats.py

STUDENTS = [
    ("Lucía", 7.5),
    ("Marcos", 4.0),
    ("Aitana", 9.0),
    ("Iván", 3.5),
    ("Carla", 6.0),
    ("Dani", 8.5),
]
PASSING = 5.0


def average(students):
    """Average of the class grades."""
    accumulator = 0
    for name, grade in students:
        accumulator += grade
    return accumulator / len(students)


def best(students):
    """Student with the highest grade (the first one if tied)."""
    candidate = students[0]
    for student in students:
        if student[1] > candidate[1]:
            candidate = student
    return candidate


def worst(students):
    """Student with the lowest grade (the first one if tied)."""
    candidate = students[0]
    for student in students:
        if student[1] < candidate[1]:
            candidate = student
    return candidate


def count_passed(students):
    """How many students reach the passing mark."""
    counter = 0
    for name, grade in students:
        if grade >= PASSING:
            counter += 1
    return counter


best_name, best_grade = best(STUDENTS)
worst_name, worst_grade = worst(STUDENTS)

print(f"Students: {len(STUDENTS)}")
print(f"Class average: {average(STUDENTS):.2f}")
print(f"Best grade: {best_name} ({best_grade})")
print(f"Worst grade: {worst_name} ({worst_grade})")
print(f"Passed: {count_passed(STUDENTS)} of {len(STUDENTS)}")

Launch it with python3 class_stats.py:

Students: 6
Class average: 6.42
Best grade: Aitana (9.0)
Worst grade: Iván (3.5)
Passed: 4 of 6

Between the lines:

  • Each student is a tuple (name, grade), and the class a list of tuples. In average and in count_passed we unpack with for name, grade in students, which reads far better than student[0] and student[1].
  • average is an accumulator: it starts at 0 (the neutral element of addition), adds all the grades and divides by len(students). In count_passed the pattern is almost the same, but it always adds 1 and only when the grade reaches the passing mark: a selective counter.
  • best and worst are the same code with a single symbol changed (> for <). Look at how they start: candidate = students[0], the first student, not an invented value. That is exactly the bug in my story: had I written candidate = ("", 0), the worst grade would always have come out as 0.
  • Ties go to the first one that shows up, because the comparison is strict (> and not >=). For our data there are no ties, but it is good to know what the code decides in that case.
  • best and worst return the whole tuple, and the program unpacks it in best_name, best_grade = best(STUDENTS).
  • The numbers add up: 7.5 + 4.0 + 9.0 + 3.5 + 6.0 + 8.5 = 38.5, and 38.5 / 6 = 6.4166… which :.2f shows as 6.42. Lucía, Aitana, Carla and Dani pass.

Try it yourself

  • Change candidate = students[0] in worst to candidate = (None, 0) and see what comes out. Why is it no longer the worst student in the class?
  • Add a function how_many_good(students) that counts the students with 7 or more.

Program 2 · A student finder

Now the data is in two parallel lists: the names and the grades, in the same order. The name of student i is in NAMES[i] and the grade in GRADES[i]. To look someone up we need their position, and with it we get their grade. We also use slices to show parts of the list.

finder.py

NAMES = ["Lucía", "Marcos", "Aitana", "Iván", "Carla", "Dani"]
GRADES = [7.5, 4.0, 9.0, 3.5, 6.0, 8.5]


def position(sequence, wanted):
    """Position of the first occurrence of wanted, or -1 if it is not there."""
    for pos, item in enumerate(sequence):
        if item == wanted:
            return pos
    return -1


def grade_of(name):
    """Student's grade, or None if not in the list."""
    pos = position(NAMES, name)
    if pos == -1:
        return None
    return GRADES[pos]


for name in ("Carla", "Pablo", "Lucía"):
    grade = grade_of(name)
    if grade is None:
        print(f"{name}: is not in the list")
    else:
        print(f"{name}: position {position(NAMES, name)}, grade {grade}")

print("First three:", NAMES[:3])
print("Last two:", NAMES[-2:])
print("Reversed order:", NAMES[::-1])
print("Is Dani there?", "Dani" in NAMES)

Launch it with python3 finder.py:

Carla: position 4, grade 6.0
Pablo: is not in the list
Lucía: position 0, grade 7.5
First three: ['Lucía', 'Marcos', 'Aitana']
Last two: ['Carla', 'Dani']
Reversed order: ['Dani', 'Carla', 'Iván', 'Aitana', 'Marcos', 'Lucía']
Is Dani there? True

Between the lines:

  • position goes through the list with enumerate, which gives (pos, item) pairs. When it finds the value, it returns the position right away; only if the loop ends without finding it does it return -1. That return -1 is outside the loop: it is the classic else mistake.
  • -1 is a good “not found” mark because it can never be a position. If we had returned 0 or False, we could not tell “is not there” from “is the first one”.
  • grade_of takes advantage of the position to go to the other list: GRADES[pos]. If it is not there, it returns None, and the program checks it with is None before showing anything.
  • Notice the order of the checks: first it asks about None and then it uses the result. If you had shown grade without checking, you would have printed None instead of a useful message.
  • The print calls after the loop show three slices and a search: [:3] are the first three, [-2:] the last two (the empty end reaches the end) and [::-1] the list backwards.
  • "Dani" in NAMES does a search and returns True or False; inside it is the same loop as position, without you having to write it.

Try it yourself

  • What happens if you look for “Lucía” and “lucía” (lowercase)? Why?
  • Add a function last_fail() that returns the position of the last student with a grade below 5. Hint: go through the list backwards with range(len(GRADES) - 1, -1, -1).

Program 3 · The grades of a term

A student’s grades during the term go in a list. We want to smooth the curve with moving averages (the average of each group of three consecutive grades), find out how many times they improved over the previous grade and, at the end, round the grades to integers modifying the list itself.

term.py

GRADES = [6.2, 7.4, 5.6, 8.1, 9.3, 7.8, 6.6]
WINDOW = 3


def average(chunk):
    """Average of a piece of the list."""
    accumulator = 0
    for grade in chunk:
        accumulator += grade
    return accumulator / len(chunk)


def moving_averages(grades, window):
    """Average of each group of 'window' consecutive grades."""
    result = []
    for start in range(len(grades) - window + 1):
        chunk = grades[start:start + window]
        result = result + [round(average(chunk), 2)]
    return result


def count_improvements(grades):
    """How many times a grade beats the previous one."""
    counter = 0
    for pos in range(1, len(grades)):
        if grades[pos] > grades[pos - 1]:
            counter += 1
    return counter


def round_in_place(grades):
    """Rounds each grade to an integer, modifying the list itself."""
    for pos in range(len(grades)):
        grades[pos] = round(grades[pos])


print("Grades:", GRADES)
print("Moving averages of 3:", moving_averages(GRADES, WINDOW))
print("Times it improved over the previous one:", count_improvements(GRADES))
round_in_place(GRADES)
print("Rounded grades:", GRADES)

Launch it with python3 term.py:

Grades: [6.2, 7.4, 5.6, 8.1, 9.3, 7.8, 6.6]
Moving averages of 3: [6.4, 7.03, 7.67, 8.4, 7.9]
Times it improved over the previous one: 3
Rounded grades: [6, 7, 6, 8, 9, 8, 7]

Between the lines:

  • moving_averages goes through the possible starts of each group: with 7 grades and a window of 3 there are 5 groups, which is why the range is len(grades) - window + 1. For each start, the slice grades[start:start + window] picks up exactly 3 consecutive grades.
  • The result is built with result = result + [...]: the empty list [] is the neutral element of concatenation, just like 0 is for addition. It is an accumulator, only of lists.
  • round(average(chunk), 2) prevents very long decimals such as 7.033333333333333.
  • count_improvements needs to compare each grade with the previous one, so a for by element will not do: it goes through positions from 1 (position 0 has no previous one) and compares grades[pos] with grades[pos - 1]. They improve at 7.4, 8.1 and 9.3, so that makes 3.
  • round_in_place is the example from the theory: since it has to change the list, it goes through the indexes and assigns to grades[pos]. It returns nothing, because the modification is already visible outside the function.
  • Also notice the order of the print calls: the moving averages and the count are computed before rounding. If you rounded first, the results would be different.

Try it yourself

  • Change round_in_place so that it goes through for grade in grades: and assigns grade = round(grade). Run it and explain why the list is not rounded.
  • Try WINDOW = 5. How many averages come out? And with WINDOW = 7?

Watch it step by step in Python Tutor

Python Tutor runs your code line by line and draws variables, functions and objects in memory. Choose Python 3 and paste a short version of the finder from Program 2:

NAMES = ["Lucía", "Marcos", "Carla"]
GRADES = [7.5, 4.0, 6.0]


def position(sequence, wanted):
    for pos, item in enumerate(sequence):
        if item == wanted:
            return pos
    return -1


pos = position(NAMES, "Marcos")
print("Position:", pos, "Grade:", GRADES[pos])
pos = position(NAMES, "Pablo")
print("Position:", pos)

Expected output:

Position: 1 Grade: 4.0
Position: -1

What to watch, step by step:

  1. At the start, the global variables show NAMES and GRADES as two list boxes with three elements each. Nothing has been called yet.
  2. When it enters position, a new frame is created with sequence pointing to the same list as NAMES. While it goes through, watch how pos and item change on each lap of enumerate.
  3. On the lap with pos = 1, item is "Marcos" and the if is met: the function leaves at that moment with return pos. Notice that it never gets to look at “Carla”.
  4. In the second call, with “Pablo”, the loop runs all three laps without finding anything, and only then does it execute return -1. That is the difference between the return inside the loop and the one outside.
  5. At the end, the global pos is -1. If you now wrote GRADES[pos], it would not give an error and it would return the last grade, because index -1 is valid. That is why you must check for -1 before using it, as grade_of does.

To wrap up

Today you have seen the most typical sequence patterns at work: accumulator and counter, extreme searches, position search with enumerate, slices and index traversals to modify. If something did not click, go back to the theory; and when you feel comfortable, move on to the exercises, where the bugs come included.

Next step: exercisesLists and tuples in Python — exercises with cuts, a class summary and passes against fails (and the six-element tuple that had only one)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *