Nesting, Comprehensions, and Choosing a Structure
Context
Collections nest inside each other, a list of records where each record is itself a mapping of field names to values. A comprehension builds a new collection from an existing one in a single expression, optionally keeping only the items that pass a test.
Nesting is the natural shape of loaded data, and comprehensions are how that shape gets reworked. A list of records becomes a list of one column's values, or a list of only the records above a threshold, in one line that says what the result is rather than describing the loop that assembled it. Choosing between a list, a mapping, and a set for a given job is a decision you now make several times per script.
Depth is where nesting hurts. Reaching two levels into a structure that is shallower than expected stops the program, or silently picks a character out of a string, and a change made deep inside a nested structure can be visible through another name that shares it. Comprehensions add their own risk by being compact enough to hide a mistake, a filter that keeps the complement of what was intended returns a perfectly valid collection of the wrong rows. The previous lessons introduced the four structures separately, this lesson combines and chooses between them.
Goal
- Build nested structures, lists of dictionaries and dictionaries of lists, and index into them at any depth.
- Trace a multi-level access expression like
data[2]["scores"][0]and predict its value before running. - Write a list comprehension that transforms an iterable in a single expression.
- Write a dict comprehension and add a filtering condition to keep only items that pass a test.
- Justify, from the shape of a problem, whether a list, tuple, dictionary, or set is the right container.
Prerequisites
Optional, as always. Nothing here is a gate, they are offered because seeing a loop turn into a comprehension once makes the rest read faster.
- YouTube - "Python Tutorial: Comprehensions - How they work and why you should be using them" by Corey Schafer. Watch the full video, it maps loops to comprehensions visually.
- Official docs - The Python Tutorial, "List Comprehensions" and "Nested List Comprehensions". Read both.
Table of Contents
- Nested Structures
- List Comprehension
- Dict Comprehension
- Comprehension Filtering
- Data Structure Selection
Theory
A table read from a file naturally becomes a list of row-dictionaries, and transforming it with a filtered comprehension is the move you will make again and again. This lesson closes the section: it nests the four containers inside one another, compresses the loops that build them into single expressions, and ends on the judgment that ties everything together, choosing the right container for the shape of a problem.
Nested Structures
What. A nested structure is a container that holds other containers, a list of dictionaries, a dictionary of lists, a list of lists. Indexing it requires one bracket per level of depth. Why. Real data is rarely flat. A dataset is rows, and each row is a record of named fields, that is a list of dictionaries. A set of columns, each a sequence of values, is a dictionary of lists. Nesting is how the four containers you learned represent the actual shape of data, so you must be able to build and navigate it. When. Use a list of dictionaries when you have many records each with named fields (the row-oriented view). Use a dictionary of lists when you have named columns each holding many values (the column-oriented view). Use a list of lists for a plain grid or matrix where positions, not names, identify cells.
A list of dictionaries is the most common shape for tabular data, each item is one row, each row maps field names to values. You index the list to pick a row, then key the dictionary to pick a field.
# A list of row-dictionaries: each dict is one sample.
rows = [
{"id": 1, "label": "cat", "score": 0.91},
{"id": 2, "label": "dog", "score": 0.84},
{"id": 3, "label": "cat", "score": 0.77},
]
# Index the LIST for a row, then key the DICT for a field.
first_label = rows[0]["label"] # row 0, then field "label" -> "cat"
third_score = rows[2]["score"] # row 2, then field "score" -> 0.77Two operations, one per level, read left to right. The type you are holding after each bracket is what decides the bracket that follows.
A dictionary of lists is the column-oriented mirror image, each key names a column, each value is the list of that column's entries. You key the dictionary to pick a column, then index the list to pick a row within it.
# A dict of lists: each key is a column, each value the column's values.
columns = {
"id": [1, 2, 3],
"label": ["cat", "dog", "cat"],
"score": [0.91, 0.84, 0.77],
}
# Key the DICT for a column, then index the LIST for a row.
labels = columns["label"] # the whole column ["cat", "dog", "cat"]
second_score = columns["score"][1] # column "score", then row 1 -> 0.84The same data lives in rows and in columns, organized row-first against column-first, and which one you want depends entirely on whether you more often ask about a record or about a field.
Deeper nesting follows the identical rule: read the access expression left to right, applying one bracket per level, and the type after each step tells you what bracket comes next.
# Three levels deep: list -> dict -> list.
data = [
{"name": "A", "scores": [0.5, 0.9]},
{"name": "B", "scores": [0.3, 0.8, 0.6]},
]
# data[1] is a dict, ["scores"] is a list, [0] is a number.
val = data[1]["scores"][0] # 0.3
# Mutation reaches through the levels the same way.
data[1]["scores"].append(0.7) # appends into the inner list of row 1Mutation travels the same path. data[1]["scores"].append(0.7) reaches the inner list and grows it in place, because nothing about being nested makes a list any less mutable, which is the same shallow-freeze point the tuple lesson made from the other direction. A full slice such as data[:] would not protect the rows either, because it copies the outer list and shares every row inside it.
DON'T:
- DON'T mismatch the bracket to the level's type. A list level needs an integer index, a dict level needs a key,
rows[0][0]fails whenrows[0]is a dictionary without a0key. - DON'T assume a missing intermediate level is safe.
data[5]["scores"]raises ondata[5]before it ever reaches"scores", guard depth-by-depth when keys or indices may be absent.
List Comprehension
What. A list comprehension builds a list from an iterable in a single expression, written [expression for item in iterable]. It is a for loop that appends, compressed into one line.
Why. Building a list by looping and appending is so common that Python gives it a dedicated, faster, more readable form. A comprehension states the transformation directly, "the square of each value", instead of the mechanics of creating an empty list and appending in a loop. It is the idiomatic way to transform a column of data.
When. Use a comprehension whenever you are building one list by transforming each item of another iterable. Fall back to a plain loop when the body needs multiple statements, side effects, or complex branching that would make the one-liner unreadable.
A comprehension is a direct compression of the build-a-list-in-a-loop pattern you already know. The expression on the left is what each for iteration would have appended.
values = [1, 2, 3, 4]
# The loop form you already know:
squares_loop = []
for v in values: # walk each value
squares_loop.append(v ** 2) # append its square
# The SAME thing as a comprehension: expression + for-clause.
squares = [v ** 2 for v in values] # [1, 4, 9, 16]Read it as "for each v in values, collect v ** 2".
The expression can be any transformation, and the source can be any iterable, including a range or the result of a method call.
# Transform strings: uppercase each label.
labels = ["cat", "dog", "bird"]
upper = [label.upper() for label in labels] # ["CAT", "DOG", "BIRD"]
# Source can be a range.
indices = [i for i in range(5)] # [0, 1, 2, 3, 4]
# Expression can combine the item with other values.
scaled = [x / 10 for x in [5, 15, 25]] # [0.5, 1.5, 2.5]Here .upper() is a string method returning an uppercased copy, covered fully in Lesson 1.5.1. In every case the structure is the same, an expression describing the result, then a for clause supplying the items.
DON'T:
- DON'T cram a multi-statement body into a comprehension. If you need more than one expression or a side effect like printing, use a plain
forloop, readability wins. - DON'T forget a comprehension builds a NEW list. It does not modify a source list, so the original is untouched.
Dict Comprehension
What. A dict comprehension builds a dictionary from an iterable in one expression, written {key_expr: value_expr for item in iterable}. It is the dictionary counterpart of the list comprehension.
Why. You constantly need to build a mapping programmatically, a name-to-index lookup, a key-to-transformed-value table, an inverted dictionary. Doing it by looping and assigning is verbose, the comprehension states the key and value rules directly in one readable line.
When. Use a dict comprehension whenever you are constructing a dictionary by deriving a key and a value for each item of an iterable, especially when transforming or inverting an existing dictionary.
The syntax adds a key: value pair before the for clause. Without a filter clause, each iteration writes one key-value pair, and a key written twice keeps only its last value. Iterating .items() of an existing dictionary is the common source.
labels = ["cat", "dog", "bird"]
# Build a name -> index lookup in one line.
index_of = {label: i for i, label in enumerate(labels)}
# {"cat": 0, "dog": 1, "bird": 2}
prices = {"a": 3.0, "b": 1.5, "c": 4.0}
# Transform every value: apply a 10% discount.
discounted = {k: v * 0.9 for k, v in prices.items()}
# {"a": 2.7, "b": 1.35, "c": 3.6}enumerate supplies both the position and the item, so the target list i, label unpacks each pair the same way any two-item tuple unpacks. The second comprehension leaves prices untouched, because a comprehension always builds something new.
Inverting a dictionary, swapping keys and values, is a classic dict-comprehension job, valid when the values are unique and immutable all the way down.
index_of = {"cat": 0, "dog": 1, "bird": 2}
# Swap key and value to invert the mapping.
label_of = {i: label for label, i in index_of.items()}
# {0: "cat", 1: "dog", 2: "bird"}This works only because the original values 0, 1, 2 are unique and immutable. Repeat a value and the inversion silently loses entries, because two identical keys cannot coexist and the later one wins.
DON'T:
- DON'T invert a dictionary whose values repeat, duplicate values become duplicate keys, and the last one silently overwrites the others.
- DON'T confuse the braces.
{x for x in it}(no colon) is a SET comprehension,{k: v for ...}(with colon) is a dict comprehension.
Comprehension Filtering
What. Comprehension filtering adds an if condition clause that keeps only the items passing the test, written [expr for item in iterable if condition]. The condition is the familiar conditional, relocated into the comprehension.
Why. Transforming and selecting together is the everyday data move, "the scores above the threshold", "the rows labeled cat", "the positive values". The filter clause does the selection inline, so one readable expression both chooses and transforms, instead of a loop with a nested if.
When. Use a filter clause whenever you want only some items of the source, by a condition. It applies to list, dict, and set comprehensions alike. Keep the condition simple, complex multi-part logic belongs in a named function or a plain loop.
The if goes after the for clause and is exactly the condition you would have written inside the loop body. Only items for which it counts as True contribute to the result.
scores = [0.2, 0.9, 0.5, 0.8, 0.1]
# Keep only scores above 0.5. The 'if' is the familiar condition.
high = [s for s in scores if s > 0.5] # [0.9, 0.8]
# The loop equivalent makes the relocation obvious:
high_loop = []
for s in scores:
if s > 0.5: # same condition, now inside the loop
high_loop.append(s)Transform and filter combine freely, the leading expression still applies to each item the if kept.
Filtering works identically in dict and set comprehensions, and pairs naturally with nested structures to query tabular data.
rows = [
{"label": "cat", "score": 0.91},
{"label": "dog", "score": 0.40},
{"label": "cat", "score": 0.77},
]
# Filter a list of dicts: scores of cat rows only.
cat_scores = [r["score"] for r in rows if r["label"] == "cat"]
# [0.91, 0.77]
# Dict comprehension with a filter: high-scoring rows keyed by label.
high_map = {r["label"]: r["score"] for r in rows if r["score"] >= 0.5}
# {"cat": 0.77} (note: duplicate "cat" key, last kept)
# Set comprehension with a filter: distinct labels that ever scored high.
high_labels = {r["label"] for r in rows if r["score"] >= 0.5}
# {"cat"}The dict comprehension in the middle is the one to look at twice. Two cat rows pass the filter, both write to the key "cat", and the later one overwrites the earlier, so a comprehension over three rows produced an entry for one of them. Nothing raised. A dict comprehension over data whose key expression is not unique quietly loses rows, and the defense is knowing whether your key is unique before you write it, and grouping into lists with setdefault when it is not, as the Assignment does.
DON'T:
- DON'T place the
iffilter before theforclause. There it is a syntax error unless it carries anelse, and with one it is the conditional-expression form at the end of this Theory, which transforms instead of filtering. - DON'T build a dict comprehension over data with repeating keys and assume all rows survive, colliding keys overwrite, just like ordinary dictionary assignment.
Data Structure Selection
What. Data structure selection is the judgment of which container, list, tuple, dictionary, or set, fits a problem, decided from the problem's shape rather than habit. Why. The right container makes the code short, correct, and fast, the wrong one makes it long and bug-prone. Choosing a list when you need fast membership turns a one-line set check into a slow scan, choosing a list when you need named lookup forces you to remember positions. Selection is the skill that makes everything else in this section pay off. When. Make the choice before you write the code, by asking three questions of the data: Does order or position matter? Must it change after creation? Do values have names, or do I only care about presence and uniqueness? The answers point directly at the container.
The decision reduces to a few questions, and each answer eliminates options.
# Ordered, may change, position matters -> LIST.
predictions = [0.9, 0.1, 0.5] # a sequence you will append to and index
# Fixed grouping, must NOT change, positions have meaning -> TUPLE.
coordinate = (3, 7) # a (row, column) that should stay constant
# Named lookup, value per key -> DICTIONARY.
config = {"lr": 0.01, "epochs": 10} # retrieve by meaningful name
# Only presence / uniqueness matters, no order -> SET.
seen_labels = {"cat", "dog"} # fast membership, distinct valuesEach of the four was chosen by the shape of its data rather than by what came to hand first.
The same underlying data often has a natural best container, and picking it simplifies the code that follows. Consider counting categories versus listing distinct ones versus keeping every value in order.
labels = ["cat", "dog", "cat", "cat", "dog"]
# "How many of each?" -> DICTIONARY (count per key).
counts = {}
for x in labels:
counts[x] = counts.get(x, 0) + 1 # {"cat": 3, "dog": 2}
# "Which distinct labels?" -> SET (uniqueness).
distinct = set(labels) # {"cat", "dog"}
# "Every label in original order?" -> LIST (order + duplicates).
ordered = labels # the list itself already fitsOne body of data, three questions, three containers. Recognizing which question you are actually asking is the whole of structure selection. Tuples and frozensets (a set that cannot be changed once built, so unlike an ordinary set it is allowed to be a dictionary key or an element of another set) are the immutable choices when a value must itself serve as a key or a set element, which is why a (row, column) coordinate keys a dictionary cleanly where a list cannot.
DON'T:
- DON'T default to a list for everything. Membership-heavy work wants a set, named lookup wants a dictionary, the wrong default makes code slow or awkward.
- DON'T pick a mutable container for a value that must stay fixed or serve as a key. Use a tuple (or frozenset) when immutability is the requirement.
Additional
The trailing-filter and the conditional-expression forms look similar but do different jobs, and mixing them up is a frequent error.
nums = [-2, 5, -1, 3]
# FILTER form: drops items that fail the condition (fewer items out).
positives = [n for n in nums if n > 0] # [5, 3]
# CONDITIONAL-EXPRESSION form: keeps all items, transforms each (same count).
clamped = [n if n > 0 else 0 for n in nums] # [0, 5, 0, 3]One selects and the other transforms, so the quickest way to tell which you have written is to ask whether the result should be shorter than the source or exactly as long.
Lab Exercises
Lab Exercise 1: Navigate Nested Data, Then Compress the Loop
Start from this list of row-dictionaries.
records = [
{"id": 1, "label": "cat", "score": 0.91, "history": [0.2]},
{"id": 2, "label": "dog", "score": 0.84, "history": [0.4, 0.6]},
{"id": 3, "label": "cat", "score": 0.77, "history": []},
]Print the label of the first row and the score of the last row. Append 0.5 to the "history" list of the second row and print that row. Then add a commented line that would fail because it uses the wrong bracket for its level, and name the error.
Then build a list of every score using a plain for loop with append, and build the identical list again as a list comprehension. Print both and confirm in a comment that they match. Finally build a list of every label uppercased, using a comprehension.
Lab Exercise 2: Comprehend, Filter, and Choose
Working from the same records, build each of the following and print it.
A dictionary mapping each row's "id" to its "score", using a dict comprehension. A list of the scores of "cat" rows only, using a filter clause. A set of the distinct labels that ever scored at or above 0.8. A list the same length as records in which any score below 0.8 is replaced by 0.0, using the conditional-expression form rather than a filter.
Then print the lengths of the filtered list and of the clamped list side by side, and add a comment stating why one is shorter than records and the other is not.
Finally, given the run records runs = [("r1", "rf"), ("r2", "svm"), ("r3", "rf"), ("r4", "knn"), ("r5", "rf")], each a (run_id, model) pair in submission order, answer three questions with three different containers: how many runs each model has, which models ran at all, and every run id in submission order. Print all three and name the container each answer used.
Solution 2: Comprehend, Filter, and Choose
# key expression, then value expression
by_id = {r["id"]: r["score"] for r in records}
print(by_id) # {1: 0.91, 2: 0.84, 3: 0.77}
# Trailing 'if' FILTERS, so the result can be shorter than the source.
cat_scores = [r["score"] for r in records if r["label"] == "cat"]
print(cat_scores) # [0.91, 0.77]
# Braces with no colon build a SET, so this collects distinct labels.
strong = {r["label"] for r in records if r["score"] >= 0.8}
print(sorted(strong)) # ['cat', 'dog']
# Leading 'if/else' TRANSFORMS, so the result is always the same length.
clamped = [r["score"] if r["score"] >= 0.8 else 0.0 for r in records]
print(clamped) # [0.91, 0.84, 0.0]
# 2 3 3
print(len(cat_scores), len(clamped), len(records))
# The filter dropped a row that failed its test. The conditional expression kept
# every row and rewrote the value of the one that failed.runs = [("r1", "rf"), ("r2", "svm"), ("r3", "rf"), ("r4", "knn"), ("r5", "rf")]
# "How many runs per model?" is a question with a value per key -> DICTIONARY.
per_model = {}
for run_id, model in runs:
per_model[model] = per_model.get(model, 0) + 1
print(per_model) # {'rf': 3, 'svm': 1, 'knn': 1}
# "Which models ran at all?" is a question of pure presence -> SET.
models = {model for run_id, model in runs}
print(sorted(models)) # ['knn', 'rf', 'svm']
# "Every run id, in submission order?" needs order -> LIST.
run_ids = [run_id for run_id, model in runs]
print(run_ids) # ['r1', 'r2', 'r3', 'r4', 'r5']DON'T:
- DON'T put the
ifbefore theforwhen you mean to filter, a leadingif ... elseis a conditional expression that transforms every item instead of dropping any. - DON'T reach for the same container out of habit, the three questions above are asked of one list and only one of them wants a list back.
Theory Assessment
Q1: One Bracket Per Level, and Which Way to Organize
For data = [{"name": "A", "scores": [0.5, 0.9]}], walk through what each step of data[0]["scores"][1] produces and say what type you are holding after each bracket. Then explain the difference between a list of dictionaries and a dictionary of lists holding the same table, and give one question each shape answers more directly.
Hint: The bracket character never changes, so what it means at any step is decided by the type you are holding when you reach it. For the second half, ask whether you more often want a whole record or a whole field.
Q2: The Loop It Compresses, and the Mapping It Builds
Write the plain loop that [v ** 2 for v in values] replaces, and say which part of the loop became which part of the comprehension. Then explain what {i: label for label, i in index_of.items()} produces given index_of = {"cat": 0, "dog": 1}, and state the condition under which inverting a dictionary this way loses entries.
Hint: Two pieces of the loop survive into the comprehension and the rest disappears entirely. For the second half, the inverted mapping's keys used to be something else, and dictionaries have one rule about keys they never relax.
Q3: Selecting Against Transforming, and Picking the Container
Explain the difference between [n for n in nums if n > 0] and [n if n > 0 else 0 for n in nums], including what each does to the length of the result. Then say which container you would choose for each of these and why: a growable ordered sequence of predictions, a (row, column) coordinate used as a dictionary key, a configuration of named settings, and a fast check of whether a label has been seen before.
Hint: For the first half, ask whether items can be dropped. For the second, three questions decide almost every case: does order matter, must it change, and do the values have names.
Q4: Three Rows In, Two Rows Out
Read the following without running it, then state what each print shows.
rows = [
{"label": "cat", "score": 0.91},
{"label": "dog", "score": 0.40},
{"label": "cat", "score": 0.77},
]
by_label = {r["label"]: r["score"] for r in rows}
filtered = [r["score"] for r in rows if r["score"] >= 0.5]
clamped = [r["score"] if r["score"] >= 0.5 else 0.0 for r in rows]
print(by_label)
print(len(rows), len(by_label), len(filtered), len(clamped))Three comprehensions run over the same three rows and produce results of different lengths. Explain each length, and say which of the three lost information without leaving any trace in its result.
Hint: Two of the three lengths are explained by something written in the comprehension itself. The third is explained by a property of the data that the comprehension never mentions.
Answer 1:
data[0] indexes the outer list and produces the dictionary {"name": "A", "scores": [0.5, 0.9]}. ["scores"] keys that dictionary and produces the list [0.5, 0.9]. [1] indexes that list and produces the number 0.9. At each step the type you are now holding decides whether the next bracket is read as a position or as a key, which is why the same character does two different jobs in one expression.
A list of dictionaries is row-oriented, each item is a complete record with named fields, so it answers "give me everything about row 2" in one step. A dictionary of lists is column-oriented, each key names a field and holds every value for it, so it answers "give me every score" in one step. The same table fits either shape, and the choice should follow whichever of those two questions you ask more often.
DON'T mismatch a bracket to its level, rows[0][0] on a list of dictionaries raises KeyError: 0 rather than an indexing error, because the dictionary read 0 as a key.
Answer 2:
The loop it replaces is:
squares = []
for v in values:
squares.append(v ** 2)The for v in values clause survives unchanged. The expression v ** 2 that was the argument to append moves to the front, before the for. What disappears entirely is the bookkeeping, there is no squares = [] and no .append call, because the brackets construct the list themselves.
{i: label for label, i in index_of.items()} produces {0: "cat", 1: "dog"}. Each (label, i) pair is unpacked and written back with the roles reversed, so the old values become the keys.
Inverting this way loses entries whenever the original values are not unique. Two entries mapping to the same value become two writes to the same key, and the later one silently overwrites the earlier, so a dictionary of five entries can invert into three with nothing raised. It also fails outright if a value is a list, a dictionary or a set, since it could not serve as a key at all.
DON'T invert a dictionary without knowing its values are unique, the loss is silent and the result looks perfectly well formed.
Answer 3:
[n for n in nums if n > 0] is a filter. The trailing if decides whether an item contributes at all, so items are dropped and the result is at most as long as the source and usually shorter.
[n if n > 0 else 0 for n in nums] is a conditional expression in the value position. Every item contributes exactly one entry, and the if/else only decides what that entry is, so the result is always exactly as long as the source. One selects, the other rewrites.
For the four cases: a growable ordered sequence of predictions is a list, because order matters, position is meaningful and it must grow. A (row, column) coordinate used as a key is a tuple, because a list cannot be a key and the two positions carry distinct meanings. A configuration of named settings is a dictionary, because each value is retrieved by a meaningful name rather than a position. A fast seen-before check is a set, because only presence and uniqueness matter and a set answers membership without scanning.
DON'T default to a list for all four, the seen-before check in particular turns from a constant-time question into a full scan.
Answer 4:
by_label prints {'cat': 0.77, 'dog': 0.4}. The second line prints 3 2 2 3.
filtered has 2 items because the trailing if dropped the dog row, whose score of 0.40 failed the test. clamped has 3 because a conditional expression in the value position never drops anything, it rewrote the dog row's score to 0.0 and kept it. Both of those lengths are readable from the comprehension itself.
by_label has 2 entries for a different reason, and nothing in that comprehension says so. Its key expression is r["label"], and two of the three rows share the label "cat". Both wrote to the same key, the later write won, and the first cat row's score of 0.91 was overwritten by the third row's 0.77. Three rows went in, two entries came out, and no error was raised because overwriting a key is ordinary dictionary behavior.
by_label is the one that lost information without a trace. From filtered you know two scores passed, and from clamped you can still see which position was rewritten. From by_label there is no trace that a third row ever existed, no trace that 0.91 was seen, and no indication that the surviving 0.77 was the second of two rather than the only one.
The general rule this illustrates is that a dict comprehension is only safe when its key expression is unique across the source. When it is not, the comprehension behaves like a silent deduplication that keeps the last occurrence, which is almost never what someone building a lookup table intended.
DON'T build a dict comprehension over rows without first knowing the key expression is unique, and don't read the result's size as the source's size.
Assignment
Assignment: Group, Filter, and the Lookup That Silently Dropped Half the Table
Build the following in one file, in this order, starting from this table.
rows = [
{"id": 1, "label": "cat", "score": 0.91},
{"id": 2, "label": "dog", "score": 0.40},
{"id": 3, "label": "cat", "score": 0.77},
{"id": 4, "label": "bird", "score": 0.55},
{"id": 5, "label": "dog", "score": 0.88},
]Group the rows by label into a dictionary mapping each label to the list of its scores, using setdefault inside a loop, and print it. Then, with a dict comprehension over that grouping, build and print a mapping from each label to its mean score, where the built-in sum(xs) adds up a list of numbers.
Then produce three things with comprehensions and print each: the ids of rows scoring at or above 0.8, a mapping from id to score for those same rows only, and the set of distinct labels that ever scored below 0.6.
Finally, demonstrate the collision rather than describing it. Build lookup = {r["label"]: r["score"] for r in rows} and print it beside len(rows). Add a comment naming which rows are absent from the result and which survived. Then set it beside the correct version, the groups mapping from label to the list of every score under that label, and print both lengths and the total number of scores each one accounts for.
Hints:
groups.setdefault(key, []).append(value)stores the empty list under a missing key before returning it, which is what makes the append land in the dictionary rather than in a discarded temporary.- For the last part, count how many of the five scores you can still find in each of the two results, that number is the whole difference between them.
Solution:
rows = [
{"id": 1, "label": "cat", "score": 0.91},
{"id": 2, "label": "dog", "score": 0.40},
{"id": 3, "label": "cat", "score": 0.77},
{"id": 4, "label": "bird", "score": 0.55},
{"id": 5, "label": "dog", "score": 0.88},
]
# 'setdefault' stores [] under a missing key, THEN returns it, so append lands in 'groups'.
groups = {}
for r in rows:
groups.setdefault(r["label"], []).append(r["score"])
print(groups) # {'cat': [0.91, 0.77], 'dog': [0.4, 0.88], 'bird': [0.55]}
# A dict comprehension over a dict-of-lists: one summary value per group.
means = {k: sum(v) / len(v) for k, v in groups.items()}
print(means) # {'cat': 0.8400000000000001, 'dog': 0.64, 'bird': 0.55}# Filter: the trailing 'if' decides which rows contribute at all.
strong_ids = [r["id"] for r in rows if r["score"] >= 0.8]
print(strong_ids) # [1, 5]
# The same filter, in a dict comprehension. 'id' is unique, so nothing collides.
strong_map = {r["id"]: r["score"] for r in rows if r["score"] >= 0.8}
print(strong_map) # {1: 0.91, 5: 0.88}
# Braces with no colon build a set, so duplicates collapse.
weak_labels = {r["label"] for r in rows if r["score"] < 0.6}
print(sorted(weak_labels)) # ['bird', 'dog']# THE COLLISION: 'label' is not unique across the rows.
lookup = {r["label"]: r["score"] for r in rows}
print(lookup, len(rows)) # {'cat': 0.77, 'dog': 0.88, 'bird': 0.55} 5
# Five rows in, three entries out. Row 1 (cat 0.91) was overwritten by row 3, and
# row 2 (dog 0.40) by row 5. The survivors are the LAST row for each label.
# The correct shape when the key repeats: one list per key, nothing discarded.
print(len(lookup), len(groups)) # 3 3
# 3 5
print(len(lookup), sum([len(v) for v in groups.values()]))The last line is the measurement that matters. Both results have three keys, so the shapes look comparable, and only counting the scores underneath them shows that lookup accounts for three of the five rows and groups accounts for all five. Two rows are simply not represented anywhere in lookup, and nothing about it looks damaged.
The rule to carry out of this is that a dict comprehension over rows is a lookup table only when its key expression is unique. When the key repeats, the comprehension is doing a silent last-wins deduplication, and if the rows arrived in a different order it could keep different survivors and still not complain. setdefault into a list is the shape to reach for the moment a key can repeat, because it makes the many-per-key relationship explicit instead of collapsing it.
One small note on the printed mean for cat. It shows as 0.8400000000000001 rather than 0.84, which is ordinary binary floating point rather than a mistake in the grouping, and it is a reminder that a computed float is rarely worth comparing for exact equality.
DON'T build a lookup keyed on a field that can repeat, and don't judge a result's completeness by its number of keys, count what is stored underneath them.
Additional Resources
Optional, and nothing depends on either. Worth a look if you want to push comprehensions further than this lesson does.
- Official docs - Functional Programming HOWTO, "Generator expressions and list comprehensions". What the same comprehension written without brackets does instead, and when producing values one at a time beats building the whole list.
- Official docs - Python Language Reference, "Displays for lists, sets and dictionaries". One comprehension with several
forandifclauses, read left to right as the nested loops it stands for, and why the loop name inside a comprehension never leaks out.
Summary
This lesson is where the four containers stop being separate topics and become one toolkit. Nesting is the recognition that real data has shape, rows that are records, columns that are sequences, grids of cells, and that the way you represent that shape is by putting containers inside containers. Once you can read an access path one bracket at a time, a list of dictionaries holds no mystery, it is just a list you index and a dictionary you key, applied in sequence. The structure looks intimidating only until you slow down and let each level tell you what the next bracket should be.
Comprehensions are the section's densest syntax because they fold three familiar things, a loop, a condition, and an expression, into a single line. That density is a feature once it is yours: a transformation reads as a transformation instead of as the bookkeeping of an empty list and an append. The discipline worth keeping is knowing when not to use one. A comprehension is for building one container by transforming and filtering another, and when the logic outgrows that, multiple statements, side effects, branching that needs room to breathe, the honest loop is the better choice. The filter clause and the conditional expression are the two pieces most often confused, and the question that separates them is simple: do you want fewer items out, or the same number transformed.
The last subtitle is the quiet point of the entire section. Every container you have learned is the right answer to some question and the wrong answer to others, and the skill that matters is asking the question before reaching for the tool. Does order matter, must it change, do values have names or does only presence matter. Three questions, four containers, and a habit of asking them that will outlast every specific method in this section. You came in able to hold one value at a time. You leave able to hold many, shape them to fit the data, and choose, deliberately, from the problem's structure, exactly how.
Cheat Sheet
Part A - Reference Table
| Need | Container | Why |
|---|---|---|
| Ordered, growable sequence | list | index by position, mutable |
| Fixed grouping, no change | tuple | immutable, a key when immutable all the way down |
| Named value lookup | dict | retrieve by meaningful key |
| Presence / uniqueness | set | fast membership, distinct |
| Many records, named fields | list of dicts | row-oriented table |
| Named columns of values | dict of lists | column-oriented table |
| Build list from iterable | [expr for x in it] |
compressed loop |
| Build dict from iterable | {k: v for x in it} |
mapping in one line |
| Keep only some items | [expr for x in it if cond] |
filter clause |
| Transform every item | [a if cond else b for x in it] |
conditional expression |
Part B - Compact Code Block
# Nested access: one bracket per level.
data = [{"v": [10, 20]}, {"v": [30]}]
x = data[0]["v"][1] # 20
# List comprehension: transform.
sq = [n ** 2 for n in [1, 2, 3]] # [1, 4, 9]
# Filter clause: keep some.
hi = [n for n in [1, 5, 2, 8] if n > 3] # [5, 8]
# Dict comprehension: build a map.
# {"a":0,"b":1}
idx = {w: i for i, w in enumerate(["a", "b"])}
# Set comprehension: distinct.
uniq = {r["c"] for r in data if "c" in r}
# Choose: count -> dict, distinct -> set, order -> list.
counts = {}
for k in ["a", "a", "b"]:
counts[k] = counts.get(k, 0) + 1 # {"a":2,"b":1}