This page is an ipython notebook, meaning you can download it and run the executable cells. It directly follows Activity 1.2. Reference answers appear after the questions, challenges, predictions, and reflections. They show one reasonable solution or interpretation, but other correct approaches may exist.
Python Objects, Data Structures, Mutability, Typing & Comprehensions¶
Tools: Python, VS Code and/or Jupyter
Learning objectives¶
By the end of this tutorial, you should be able to:
inspect Python objects with
type()andid();distinguish names from objects;
explain mutable vs. immutable objects;
recognize shared references;
use lists, tuples, dictionaries, and sets;
explain dynamic typing;
use basic type annotations;
write list and dictionary comprehensions;
connect data-structure choices to statistical computing.
Part 0 — Set up¶
To begin this activity, open your repository for the course on GitHub, and open its Codespace.
Inside your repository create:
notebooks/week02_data_structures.ipynbPart 1 — Everything is an object¶
Run:
x = 42
type(x)
id(x)
repr(x)Then:
x = "hello"
type(x)
id(x)
repr(x)Questions¶
Did the type change?
Did the identity change?
What happened to the integer object?
Does the name
xhave a permanent type?
Write your answers in a Markdown cell.
Reference answer¶
Did the type change? Yes.
xfirst refers to an integer (int) and then to a string (str).Did the identity change? Yes. Rebinding
xmakes it refer to a different object, so itsid()changes.What happened to the integer object? The name
xstopped referring to it. If no other references to that integer object remain, Python is free to reclaim it. (For small integers, Python may internally reuse objects, so the exactid()behavior should not be treated as a memory-management experiment.)Does
xhave a permanent type? No. The object has a type; the namexcan be rebound to objects of different types.
Part 2 — Names and objects¶
Run:
x = [1, 2, 3]
y = x
x is yThen:
x.append(4)
print(x)x ─────┐
├────► ?
y ─────┘Replace ? with the object.
Verify by running:
print(y)Reference answer¶
y is:
[1, 2, 3, 4]x and y refer to the same list object. Therefore mutating the list through x is visible through y as well.
Part 3 — Mutation vs. rebinding¶
Compare:
x = [1, 2, 3]
y = x
x.append(4)with:
x = [1, 2, 3]
y = x
x = [1, 2, 3, 4]For each, inspect:
x
y
x is yQuestion¶
What is the difference between x.append(4) and x = [1, 2, 3, 4]?
Reference answer¶
x.append(4) mutates the existing list in place. If y = x, both names still refer to that same list, so both see the appended value and x is y remains True.
By contrast,
x = [1, 2, 3, 4]rebinds the name x to a new list object. y still refers to the original [1, 2, 3], so x is y becomes False.
Part 4 — Mutable vs. immutable¶
Create:
a = 10
b = 3.14
c = "hello"
d = (1, 2, 3)
e = [1, 2, 3]
f = {"a": 1}
g = {1, 2, 3}Create a table:
| Object | Type | Mutable? |
|---|---|---|
a | ||
b | ||
c | ||
d | ||
e | ||
f | ||
g |
Test your answers with operations such as:
e.append(4)
f["b"] = 2Try:
d[0] = 99Explain the result.
Reference answer¶
| Object | Type | Mutable? |
|---|---|---|
a | int | No |
b | float | No |
c | str | No |
d | tuple | No |
e | list | Yes |
f | dict | Yes |
g | set | Yes |
These operations work because lists and dictionaries are mutable:
e.append(4)
f["b"] = 2But:
d[0] = 99raises a TypeError because tuples are immutable: an existing tuple cannot have one of its elements replaced in place.
Part 5 — A function that changes its input¶
Run:
def add_value(values):
values.append(100)
data = [1, 2, 3]
add_value(data)
print(data)Then create a non-mutating version:
def add_value(values):
result = values.copy()
result.append(100)
return result
data = [1, 2, 3]
new_data = add_value(data)
print(data)
print(new_data)Reflection¶
Which style would you prefer for a statistical function, and why?
Reference answer¶
For most statistical/data-science functions, I would usually prefer the non-mutating version unless in-place modification is intentional and clearly documented.
Returning a new object makes the function easier to reason about because the caller’s original data remain unchanged. This reduces unexpected side effects, makes debugging/testing easier, and is especially helpful when the same data object is used elsewhere in an analysis.
Mutation can still be appropriate when memory/performance matters, but it should be explicit.
Part 6 — Shallow copying¶
Run:
x = [[1, 2], [3, 4]]
y = x.copy()
print(x is y)
print(x[0] is y[0])Then:
x[0].append(99)
print(x)
print(y)Explain why the inner list changed in both objects.
Reference answer¶
x.copy() makes a shallow copy. The outer lists are different objects, so:
x is yis False.
However, the nested lists are still shared. Therefore:
x[0] is y[0]is True.
When 99 is appended to x[0], the shared inner list changes, so the change appears inside both x and y.
Part 7 — Lists¶
Create:
scores = [72, 81, 91, 68, 88, 95, 77, 84]Practice:
scores[0]
scores[-1]
scores[2:5]
len(scores)
scores.append(90)
scores.sort()
scores.reverse()Reference solution¶
One solution is:
n = len(scores)
minimum = min(scores)
maximum = max(scores)
mean_score = sum(scores) / len(scores)
n, minimum, maximum, mean_scoreWith the list as it exists after the earlier append, sort, and reverse operations, the values themselves are unchanged except that 90 was added, so the number of observations is 9, the minimum is 68, the maximum is 95, and the mean is approximately 82.89.
Part 8 — Tuples¶
Create:
flagstaff = (35.1983, -111.6513)Access:
flagstaff[0]
flagstaff[1]Try:
flagstaff[0] = 40Explain why a tuple could make conceptual sense for a coordinate pair.
Reference answer¶
A latitude/longitude coordinate is naturally a fixed pair of values. A tuple communicates that the pair belongs together and is not intended to be modified element-by-element.
For example:
flagstaff = (35.1983, -111.6513)Using a tuple does not make the geographic value inherently safer or more correct, but its immutability expresses the intended structure clearly.
Part 9 — Dictionaries¶
Create:
student = {
"name": "Alex",
"program": "Statistics and Data Science",
"year": 1,
"courses": ["STA 556", "STA 570"]
}Practice:
student["name"]
student["courses"]
student["graduation_year"] = 2028
"name" in student
student.get("email")Why might .get() be preferable when a key may not exist?
Reference answer¶
Dictionary indexing such as:
student["email"]raises a KeyError if the key does not exist.
By contrast:
student.get("email")returns None by default, or a value you specify:
student.get("email", "not provided")This is useful when a key is optional and its absence is expected rather than exceptional.
Part 10 — Sets¶
Create:
states = [
"Arizona", "Arizona", "Utah",
"Nevada", "Arizona", "Utah"
]
unique_states = set(states)Test:
unique_states
"Arizona" in unique_states
"California" in unique_statesExplain why a set can be useful for unique categories and membership testing.
Reference answer¶
A set automatically keeps unique values, so converting the list of state names to a set removes duplicates.
Sets are also designed for efficient membership testing:
"Arizona" in unique_statesThis makes them useful for collections such as allowed categories, observed labels, or unique groups when ordering and duplicates are not important.
Part 11 — Choosing data structures¶
Choose a data structure for each:
A. A sequence of ages where order matters.
B. A fixed latitude/longitude coordinate.
C. Subject ID → treatment group.
D. Unique treatment groups.
E. A collection that will be repeatedly modified.
Explain each answer.
Reference answer¶
A. Sequence of ages where order matters → list
Lists preserve order and can be modified.
B. Fixed latitude/longitude coordinate → tuple
A tuple represents a fixed ordered pair and communicates that it should not be changed in place.
C. Subject ID → treatment group → dict
A dictionary naturally maps a unique key (subject ID) to a value (treatment group).
D. Unique treatment groups → set
A set stores unique values and supports fast membership checks.
E. Collection repeatedly modified → usually list
Lists are mutable and support append/remove/update-style operations. The best choice could differ if the collection has key-value or uniqueness requirements.
Part 12 — Dynamic typing¶
Run:
x = 10
print(type(x))
x = "ten"
print(type(x))
x = [10]
print(type(x))Then:
x = "10"
x + 5Explain why Python can be dynamically typed while still having types.
Reference answer¶
Dynamically typed does not mean “untyped.” Every Python object still has a type.
The dynamic part is that a name is not permanently declared to have one type. For example, x can first refer to an int, then a str, then a list.
The expression:
"10" + 5fails because Python still respects object types: it does not automatically combine a string and an integer using +.
Part 13 — Type annotations¶
Write:
def mean(values: list[float]) -> float:
return sum(values) / len(values)Run:
mean([1.0, 2.0, 3.0])
mean.__annotations__Then:
def add(a: int, b: int) -> int:
return a + b
add(2, 3)
add("hello", "world")Explain what the annotations communicate and why annotations do not make Python statically typed.
Reference answer¶
In:
def mean(values: list[float]) -> float:list[float] communicates that values is intended to be a list of floating-point values, and -> float communicates that the function is intended to return a float.
These are annotations, not automatic runtime restrictions. Likewise:
def add(a: int, b: int) -> int:
return a + bcan still be called with strings, and Python will concatenate them because the annotations are not enforced by the interpreter.
Annotations improve documentation, editor support, and static type checking, but they do not make Python statically typed.
Part 14 — List comprehensions¶
Traditional loop:
squares = []
for x in range(10):
squares.append(x ** 2)Rewrite:
squares = [x ** 2 for x in range(10)]Then:
even_squares = [
x ** 2
for x in range(21)
if x % 2 == 0
]Write the equivalent traditional loop and compare readability.
Reference solution¶
The traditional-loop equivalent is:
even_squares = []
for x in range(21):
if x % 2 == 0:
even_squares.append(x ** 2)For this simple transformation/filter, the comprehension is arguably clearer because the operation fits naturally in one readable expression:
even_squares = [x ** 2 for x in range(21) if x % 2 == 0]If the logic required several branches or intermediate calculations, the explicit loop would usually be easier to read.
Part 15 — Statistical example¶
Create:
values = [12.1, 10.4, 15.2, 9.8, 13.7]
mean = sum(values) / len(values)
centered = [x - mean for x in values]
squared_deviations = [(x - mean) ** 2 for x in values]Verify:
sum(centered)Then calculate the population variance from squared_deviations.
Reference solution¶
The population variance is the mean of the squared deviations:
population_variance = sum(squared_deviations) / len(squared_deviations)
population_varianceFor these values, it is approximately:
4.1556Because this asks for the population variance, divide by n, not n - 1.
Part 16 — Dictionary comprehensions¶
Run:
squares = {x: x ** 2 for x in range(10)}
cubes = {x: x ** 3 for x in range(10)}Create a dictionary mapping 1–10 to whether each number is even:
even = {
x: x % 2 == 0
for x in range(1, 11)
}Part 17 — Structured observations¶
Create:
observations = [
{"id": 1, "score": 82},
{"id": 2, "score": 91},
{"id": 3, "score": 76},
{"id": 4, "score": 88},
{"id": 5, "score": 94},
]Extract scores:
scores = [obs["score"] for obs in observations]Filter scores ≥ 90:
high_scores = [
obs["score"]
for obs in observations
if obs["score"] >= 90
]Create ID → score:
score_by_id = {
obs["id"]: obs["score"]
for obs in observations
}Challenge¶
Create a dictionary containing only observations with scores ≥ 85.
Reference solution¶
high_score_by_id = {
obs["id"]: obs["score"]
for obs in observations
if obs["score"] >= 85
}Result:
{2: 91, 4: 88, 5: 94}Note: the earlier high_scores example says “scores ≥ 85” but its code uses >= 90. For the challenge here, use the stated threshold of 85.
Part 18 — When not to use a comprehension¶
Consider:
results = []
for person in people:
if person["age"] >= 18:
if person["status"] == "active":
transformed = complicated_function(person)
results.append(transformed)Consider whether compressing this into one comprehension would actually improve the code.
Principle¶
Use comprehensions when they make simple transformations clearer.
Do not use them merely to minimize lines.
Reference answer¶
Compressing this entire block into one comprehension would probably make it harder to read, not better.
There are two conditions plus a nontrivial transformation:
if person["age"] >= 18:
if person["status"] == "active":
transformed = complicated_function(person)A comprehension can technically express this, but the explicit loop makes the control flow and intermediate transformation easier to inspect and debug. The goal is readable code, not the fewest lines.
Part 19 — Mini data-science challenge¶
Create:
participants = [
{"id": 101, "age": 24, "group": "A", "score": 81},
{"id": 102, "age": 31, "group": "B", "score": 94},
{"id": 103, "age": 27, "group": "A", "score": 88},
{"id": 104, "age": 19, "group": "B", "score": 72},
{"id": 105, "age": 42, "group": "A", "score": 91},
{"id": 106, "age": 35, "group": "B", "score": 85},
]Using comprehensions, calculate:
ids = ... # a list of IDs
scores = ... # a list of scores
high_scores = ... # a list of scores >= 90
older_ids = ... # a list of IDs of people aged >=35
groups = ... # a list of group (see if you can make this a set!)
score_by_id = ... # a dictionary with ID as the key and score as the valueThen create:
summary = {
"n": ...,
"groups": ...,
"mean_score": ...,
"max_score": ...,
"high_performers": ... # IDs of students with scores >= 90
}Add:
summary: dict = ...Reference solution¶
One possible solution is:
ids = [p["id"] for p in participants]
scores = [p["score"] for p in participants]
high_scores = [
p["score"]
for p in participants
if p["score"] >= 90
]
older_ids = [
p["id"]
for p in participants
if p["age"] >= 35
]
groups = {p["group"] for p in participants}
score_by_id = {
p["id"]: p["score"]
for p in participants
}A possible summary is:
summary = {
"n": len(participants),
"groups": {p["group"] for p in participants},
"mean_score": sum(p["score"] for p in participants) / len(participants),
"max_score": max(p["score"] for p in participants),
"high_performers": [
p["id"]
for p in participants
if p["score"] >= 90
]
}This gives a mean score of approximately 85.17, a maximum score of 94, and high performers [102, 105].
A basic annotation is:
summary: dict = {
# same contents as above
}A more specific annotation could be used later after introducing richer typing tools.
Part 20 — Compare loops and comprehensions¶
Choose one previous problem and implement it both ways.
Answer:
Which is shorter?
Which is clearer?
Which would you prefer in production code?
Would your answer change if the transformation became more complicated?
Reference answer¶
For a simple example such as squaring even numbers:
Which is shorter? The comprehension.
Which is clearer? For a simple transformation/filter, usually the comprehension.
Which would I prefer in production? The comprehension if the team can read it immediately.
Would the answer change for more complicated logic? Yes. As branching, side effects, or intermediate steps increase, an explicit loop is often clearer and easier to debug.
Readability is the deciding criterion, not line count.
Part 21 — Git checkpoint¶
From your Week 1 repository:
git status
git diff
git add .
git commit -m "Complete Week 2 data structures exercises"
git pushAlign what you’ve done here with the Week 1 workflow:
Create → Explore → Save → Inspect → Commit → PushPart 22 — Reflection¶
Answer these questions in a Markdown cell.
What is the relationship between a variable name and a Python object?
Why can mutability create unexpected behavior when passing objects to functions?
Why would you choose a dictionary instead of a list?
What does dynamically typed mean?
What problem can type annotations help solve?
When does a comprehension improve code?
Why should a statistician care about Python’s object model and data structures?
Reference answers¶
What is the relationship between a variable name and a Python object?
A name is a reference to an object. The object has a type, value, and identity; the name can later be rebound to another object.Why can mutability create unexpected behavior when passing objects to functions?
A function may receive a reference to the same mutable object held by the caller. If the function mutates it, the caller sees the change too.Why choose a dictionary instead of a list?
Use a dictionary when values are naturally accessed by meaningful keys (for example, subject ID → group) rather than primarily by position.What does dynamically typed mean?
Names are not permanently declared with one type; they can refer to objects of different types at runtime.What problem can type annotations help solve?
They communicate intended interfaces, improve editor/static-analysis support, and help catch type mismatches before runtime when used with a type checker.When does a comprehension improve code?
When a simple transformation/filter can be expressed more directly and readably than an explicit loop.Why should a statistician care about Python’s object model and data structures?
Because aliasing, mutation, container choice, and data representation affect correctness, reproducibility, performance, and how safely statistical workflows manipulate data.
Completion checklist¶
Created Week 2 notebook/script
Inspected objects using
type()andid()Demonstrated shared references
Demonstrated mutation and rebinding
Explored mutable and immutable objects
Explored shallow copying
Used lists, tuples, dictionaries, and sets
Explained dynamic typing
Added type annotations
Created list comprehensions
Created dictionary comprehensions
Used comprehensions with conditions
Completed the participant data challenge
Compared loops with comprehensions
Created a statistical summary object
Committed Week 2 work to Git
Pushed Week 2 work to GitHub
What you should now understand¶
Python names
↓
Objects
↓
Identity + type + value
↓
References
↓
Mutability
↓
Data structures
↓
Comprehensions
↓
Readable computational code
↓
Pandas DataFrames next