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.
Table of Contents
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. Inaverageand incount_passedwe unpack withfor name, grade in students, which reads far better thanstudent[0]andstudent[1]. averageis an accumulator: it starts at 0 (the neutral element of addition), adds all the grades and divides bylen(students). Incount_passedthe pattern is almost the same, but it always adds 1 and only when the grade reaches the passing mark: a selective counter.bestandworstare 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 writtencandidate = ("", 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. bestandworstreturn the whole tuple, and the program unpacks it inbest_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
:.2fshows as 6.42. Lucía, Aitana, Carla and Dani pass.
Try it yourself
- Change
candidate = students[0]inworsttocandidate = (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:
positiongoes through the list withenumerate, 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. Thatreturn -1is outside the loop: it is the classicelsemistake.-1is a good “not found” mark because it can never be a position. If we had returned0orFalse, we could not tell “is not there” from “is the first one”.grade_oftakes advantage of the position to go to the other list:GRADES[pos]. If it is not there, it returnsNone, and the program checks it withis Nonebefore showing anything.- Notice the order of the checks: first it asks about
Noneand then it uses the result. If you had showngradewithout checking, you would have printedNoneinstead of a useful message. - The
printcalls 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 NAMESdoes a search and returnsTrueorFalse; inside it is the same loop asposition, 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 withrange(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_averagesgoes through the possible starts of each group: with 7 grades and a window of 3 there are 5 groups, which is why the range islen(grades) - window + 1. For each start, the slicegrades[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_improvementsneeds to compare each grade with the previous one, so aforby element will not do: it goes through positions from 1 (position 0 has no previous one) and comparesgrades[pos]withgrades[pos - 1]. They improve at 7.4, 8.1 and 9.3, so that makes 3.round_in_placeis the example from the theory: since it has to change the list, it goes through the indexes and assigns togrades[pos]. It returns nothing, because the modification is already visible outside the function.- Also notice the order of the
printcalls: 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_placeso that it goes throughfor grade in grades:and assignsgrade = round(grade). Run it and explain why the list is not rounded. - Try
WINDOW = 5. How many averages come out? And withWINDOW = 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:
- At the start, the global variables show
NAMESandGRADESas two list boxes with three elements each. Nothing has been called yet. - When it enters
position, a new frame is created withsequencepointing to the same list asNAMES. While it goes through, watch howposanditemchange on each lap ofenumerate. - On the lap with
pos = 1,itemis"Marcos"and theifis met: the function leaves at that moment withreturn pos. Notice that it never gets to look at “Carla”. - 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 thereturninside the loop and the one outside. - At the end, the global
posis-1. If you now wroteGRADES[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-1before using it, asgrade_ofdoes.
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.
Did this article help you? Here is how you can support SergioLearns →
