This page is an ipython notebook, meaning you can download it and run the executable cells. It directly follows Activity 2.1. Reference answers appear after the complete Part they answer, so they do not interrupt setup code, comparison examples, or multi-cell questions. They show one reasonable solution or interpretation, but other correct approaches may exist.
DataFrames, Indexing, Slicing & Filtering¶
Tools: Python, pandas, VS Code, and/or Jupyter
Learning objectives¶
By the end of this tutorial, you should be able to:
create and inspect pandas DataFrames;
distinguish Series from DataFrames;
select columns;
select rows using
.locand.iloc;slice rows and columns;
create Boolean conditions;
filter with one or several conditions;
use
.isin(),.isna(), and.notna();modify selected observations with
.loc;translate statistical inclusion criteria into reproducible code;
validate a filtered dataset.
Part 0 — Set up¶
Use the project/repository you have been building during the earlier parts of this course.
Create:
notebooks/week03_dataframes.ipynbAnd at the top create a code cell to import pandas:
import pandas as pdPart 1 — Create a DataFrame¶
Create:
data = {
"id": [101, 102, 103, 104, 105, 106],
"age": [24, 31, 27, 19, 42, 35],
"group": ["A", "B", "A", "B", "A", "B"],
"score": [81, 94, 88, 72, 91, 85]
}
df = pd.DataFrame(data)Display:
dfThen inspect:
df.head()
df.shape
df.columns
df.index
df.dtypes
df.info()
df.describe()Questions¶
How many observations are there?
How many variables?
Which columns are numeric?
What is the default index?
What does
df.shapereturn?
Reference answer¶
There are 6 observations.
There are 4 variables.
The numeric columns are
id,age, andscore.The default index is a
RangeIndexrunning from0to5.df.shapereturns:
(6, 4)The first value is the number of rows and the second is the number of columns.
age = df["age"]
type(age)Now:
age_df = df[["age"]]
type(age_df)Question¶
Explain the difference between:
df["age"]and:
df[["age"]]Reference answer¶
df["age"]returns a pandas Series, which is one-dimensional.
df[["age"]]returns a DataFrame containing one column, so it remains two-dimensional.
This difference can matter because some pandas operations expect a Series while others expect a DataFrame.
Part 3 — Select columns¶
Select:
df["score"]Then:
df[["age", "score"]]Reference answer¶
analysis_df = df[["id", "group", "score"]]df.iloc[0]Then:
df.iloc[0:3]Then:
df.iloc[[0, 2, 5]]Now select rows 1–3 and columns 1–2:
df.iloc[1:4, 1:3]Questions¶
What does the first argument to
.iloc[]control?What does the second argument control?
Is the stop value included in positional slicing?
Reference answer¶
The first argument to
.iloc[]controls the rows selected by integer position.The second argument controls the columns selected by integer position.
The stop value in a positional slice is not included.
For example:
df.iloc[1:4, 1:3]selects row positions 1, 2, 3 and column positions 1, 2.
df = df.set_index("id")Inspect:
df
df.indexCompare:
df.loc[101]with:
df.iloc[0]Reflection¶
They identify the same observation here. Why do the two commands mean different things?
Reference answer¶
Although these identify the same observation here, they use different rules:
df.loc[101]selects the row whose index label is 101.
df.iloc[0]selects the first row by position.
They only agree here because label 101 happens to be on the first row.
df.loc[101]df.loc[[101, 103, 105]]Then:
df.loc[
[101, 103],
["age", "score"]
]Challenge¶
Select IDs 102, 104, and 106 and display only:
age
group
scoreReference answer¶
df.loc[
[102, 104, 106],
["age", "group", "score"]
]Part 7 — .loc vs. .iloc¶
Create:
small = pd.DataFrame(
{"value": [10, 20, 30, 40]},
index=[101, 205, 310, 412]
)Compare:
small.loc[205]with:
small.iloc[1]Complete:
.loc uses ______________________
.iloc uses ______________________Reference answer¶
.loc uses index labels
.iloc uses integer positionsIn the example:
small.loc[205]uses the label 205, while:
small.iloc[1]uses position 1, meaning the second row.
Part 8 — Slicing¶
Try:
df.iloc[1:4]Then:
df.loc[102:105]Count the returned rows.
Question¶
Why does label-based slicing behave differently from positional slicing?
Reference answer¶
df.iloc[1:4]returns 3 rows because positional slicing excludes the stop position.
df.loc[102:105]returns 4 rows: labels 102, 103, 104, and 105.
Label-based slicing with .loc includes the ending label when that label exists in the index.
df.loc[:, ["age", "score"]]Then:
df.loc[
102:105,
["age", "score"]
]Challenge¶
Select IDs 101 through 104 and show only group and score.
Reference answer¶
df.loc[
101:104,
["group", "score"]
]Because .loc uses labels, IDs 101, 102, 103, and 104 are included.
condition = df["score"] > 80Inspect:
condition
type(condition)Now:
df.loc[condition]Question¶
What does each True and False represent?
Reference answer¶
Each True or False corresponds to one row of the DataFrame.
For:
condition = df["score"] > 80True means that row has a score greater than 80, while False means it does not.
The Boolean Series can then be used as a row filter.
df.loc[df["score"] > 80]Then:
df.loc[df["age"] >= 30]Then:
df.loc[df["group"] == "A"]Challenge¶
Create separate subsets for participants who are:
younger than 30;
in group B;
scoring at least 85.
Reference answer¶
Younger than 30:
younger = df.loc[df["age"] < 30]IDs: 101, 103, 104
Group B:
group_b = df.loc[df["group"] == "B"]IDs: 102, 104, 106
Score at least 85:
high_scores = df.loc[df["score"] >= 85]IDs: 102, 103, 105, 106
df.loc[
(df["age"] < 30) &
(df["score"] > 80)
]Use OR:
df.loc[
(df["group"] == "A") |
(df["score"] > 90)
]Use NOT:
df.loc[
~(df["group"] == "A")
]Challenge¶
Find participants who are at least 30 years old and score at least 85.
df.loc[
(df["group"] == "A") &
(df["score"] >= 90)
]Reference answer¶
The only matching ID is:
105because ID 105 is in group A and has a score of 91, which is at least 90.
If your prediction matched this result, your Boolean conditions were interpreted correctly.
df.loc[
df["score"] >= 85,
["age", "group", "score"]
]Challenge¶
Return only group and score for participants aged at least 30.
Reference answer¶
df.loc[
df["age"] >= 30,
["group", "score"]
]This returns IDs 102, 105, and 106 with only the requested columns.
df2 = pd.DataFrame(
{
"id": range(201, 209),
"group": ["A", "B", "C", "A", "C", "B", "A", "C"],
"score": [81, 94, 88, 72, 91, 85, 79, 96]
}
).set_index("id")Select groups A and C:
df2.loc[
df2["group"].isin(["A", "C"])
]Challenge¶
Select observations in groups B or C and with score at least 85.
Reference answer¶
df2.loc[
df2["group"].isin(["B", "C"])
& (df2["score"] >= 85)
]The matching IDs are:
202, 203, 205, 206, 208missing_df = pd.DataFrame(
{
"id": [1, 2, 3, 4, 5],
"age": [21, 32, None, 45, 28],
"score": [81, None, 77, 92, 88]
}
).set_index("id")Inspect:
missing_df
missing_df.isna()
missing_df.isna().sum()Find missing scores:
missing_df.loc[
missing_df["score"].isna()
]Find observed scores:
missing_df.loc[
missing_df["score"].notna()
]Reflection¶
Why is score = 0 different from score = missing in statistical analysis?
Reference answer¶
A score of 0 is an observed value. It means the score was measured and its value was zero.
A missing score means the value was not observed or is unavailable.
Treating missing values as zero would change the meaning of the data and could distort summaries such as means, distributions, and model estimates.
df["performance"] = "medium"Then:
df.loc[
df["score"] >= 90,
"performance"
] = "high"Then:
df.loc[
df["score"] < 80,
"performance"
] = "low"Challenge¶
Create an age_group variable:
young → age < 30
older → age >= 30using .loc.
Reference answer¶
One clear solution is:
df["age_group"] = "older"
df.loc[
df["age"] < 30,
"age_group"
] = "young"Alternatively, you could initialize "young" first and then assign "older" to ages 30 or above.
The important point is that .loc explicitly identifies both the rows being changed and the column being changed.
df[df["score"] > 80]["score"] = 100Prefer:
df.loc[
df["score"] > 80,
"score"
] = 100Question¶
Why is the .loc version clearer? Connect your answer to Week 2’s discussion of references and mutation.
Reference answer¶
The .loc version is clearer because it performs the row selection and assignment in one explicit operation:
df.loc[
df["score"] > 80,
"score"
] = 100By contrast:
df[df["score"] > 80]["score"] = 100first creates an intermediate object and then tries to modify it. That intermediate object may be a copy rather than the original DataFrame.
This connects to the earlier discussion of references and mutation: it is important to know which object is actually being modified.
Part 19 — Define an analysis population¶
Suppose the study protocol says:
Include adults in group A who have a non-missing score.
Write:
analysis = df.loc[
(df["age"] >= 18) &
(df["group"] == "A") &
(df["score"].notna())
]Reference answer¶
The filter is:
analysis = df.loc[
(df["age"] >= 18)
& (df["group"] == "A")
& (df["score"].notna())
]For the current dataset, the included IDs are:
101, 103, 105The key idea is that these conditions define the analysis population, not merely a convenient subset of rows.
Part 20 — Write criteria first¶
Suppose your analysis question is:
What are the scores among older high-performing participants?
Before writing code, define:
Age criterion:
Score criterion:
Missing-data criterion:
Variables required:Then translate the rules into pandas:
eligible = (
(df["age"] >= 30) &
(df["score"] >= 85) &
(df["score"].notna())
)
analysis_df = df.loc[
eligible,
["age", "group", "score"]
]Reference answer¶
A reasonable set of criteria is:
Age criterion: age >= 30
Score criterion: score >= 85
Missing-data criterion: score must be observed
Variables required: age, group, scoreThe supplied pandas code implements exactly those rules.
With the current df, the resulting IDs are:
102, 105, 106Part 21 — Mini data-analysis challenge¶
Create:
participants = pd.DataFrame(
{
"id": range(1001, 1011),
"age": [22, 34, 29, 41, 19, 37, 26, 45, 31, 28],
"group": ["A", "B", "A", "C", "B", "A", "C", "B", "A", "C"],
"score": [78, 91, 85, 88, 72, 95, 81, 67, 90, 84]
}
).set_index("id")Complete each task.
A. Participants aged 30 or older¶
older = ...B. Participants in groups A or C¶
groups_ac = ...C. Participants with scores at least 85¶
high_scores = ...D. Participants aged at least 30 AND scoring at least 85¶
older_high = ...E. Only group and score for high scorers¶
high_score_summary = ...F. Group A participants scoring at least 80¶
group_a_good = ...G. Participants who are NOT in group B¶
not_group_b = ...Reference answer¶
A. Participants aged 30 or older¶
older = participants.loc[
participants["age"] >= 30
]IDs: 1002, 1004, 1006, 1008, 1009
B. Participants in groups A or C¶
groups_ac = participants.loc[
participants["group"].isin(["A", "C"])
]IDs: 1001, 1003, 1004, 1006, 1007, 1009, 1010
C. Participants with scores at least 85¶
high_scores = participants.loc[
participants["score"] >= 85
]IDs: 1002, 1003, 1004, 1006, 1009
D. Participants aged at least 30 AND scoring at least 85¶
older_high = participants.loc[
(participants["age"] >= 30)
& (participants["score"] >= 85)
]IDs: 1002, 1004, 1006, 1009
E. Only group and score for high scorers¶
high_score_summary = participants.loc[
participants["score"] >= 85,
["group", "score"]
]F. Group A participants scoring at least 80¶
group_a_good = participants.loc[
(participants["group"] == "A")
& (participants["score"] >= 80)
]IDs: 1003, 1006, 1009
G. Participants who are NOT in group B¶
not_group_b = participants.loc[
participants["group"] != "B"
]IDs: 1001, 1003, 1004, 1006, 1007, 1009, 1010
Part 22 — Verify every answer¶
For each result, inspect:
result.shape
result.head()
result.indexThen manually compare it with the original dataset.
Ask:
Does every included row satisfy the condition?
and:
Did I exclude anyone who should be included?
Code that executes successfully is not necessarily correct.
Reference answer¶
Successful execution does not prove that a filter is logically correct.
For each result, check both directions:
Every included row should satisfy the stated criterion.
Every row that satisfies the criterion should have been included.
Inspecting .shape, .head(), and .index helps verify that the result matches the intended analysis population.
def select_high_scores(
data: pd.DataFrame,
minimum_score: float
) -> pd.DataFrame:
...It should return observations whose scores are at least minimum_score.
Test:
select_high_scores(participants, 85)and:
select_high_scores(participants, 90)Extension¶
Modify the function so the caller can also specify which columns to return.
Reference answer¶
A reusable function is:
def select_high_scores(
data: pd.DataFrame,
minimum_score: float
) -> pd.DataFrame:
return data.loc[
data["score"] >= minimum_score
]For the extension:
def select_high_scores(
data: pd.DataFrame,
minimum_score: float,
columns=None
) -> pd.DataFrame:
mask = data["score"] >= minimum_score
if columns is None:
return data.loc[mask]
return data.loc[
mask,
columns
]Example:
select_high_scores(
participants,
85,
["group", "score"]
)good_data = participants.loc[
participants["score"] > 80
]Six months later, someone asks:
Why did you choose 80?
Improve the workflow:
MINIMUM_SCORE = 80
good_data = participants.loc[
participants["score"] > MINIMUM_SCORE
]Discussion¶
Does naming the threshold improve reproducibility? What documentation is still missing?
Reference answer¶
Naming the threshold improves reproducibility because the value is explicit and can be changed in one place:
MINIMUM_SCORE = 80However, the name alone does not explain why 80 was chosen.
Useful documentation might include:
the scientific or practical rationale for the threshold;
whether the rule should be
> 80or>= 80;the source of the rule (protocol, policy, prior literature, etc.);
when or why the threshold was changed.
Reproducibility requires both executable code and enough context to understand the analytical choices.
git statusThen:
git add .Commit:
git commit -m "Complete Week 3 pandas indexing and filtering"Push:
git pushContinue the workflow established in Week 1:
Create
↓
Inspect
↓
Transform
↓
Validate
↓
Document
↓
Commit
↓
SharePart 26 — Final reflection¶
Answer in Markdown.
1. DataFrames¶
What does a pandas DataFrame provide that a list of dictionaries does not?
2. Series¶
What is the difference between:
df["age"]and:
df[["age"]]df["score"] >= 85produce before it is used to subset a DataFrame?
5. Analysis populations¶
Why is a pandas filter more than simply a programming operation?
6. Validation¶
Why should you inspect a filtered dataset even if no Python error occurred?
7. Reproducibility¶
How can explicit filtering criteria make statistical analyses easier to reproduce?
Completion checklist¶
Created the Week 3 notebook
Imported pandas
Created a DataFrame
Used
head(),shape,dtypes,info(), anddescribe()Distinguished Series and DataFrames
Selected individual columns
Selected multiple columns
Used
.ilocUsed
.locCreated a meaningful index
Compared positional and label selection
Practiced slicing
Created Boolean conditions
Filtered using one condition
Filtered using multiple conditions
Used
&,|, and~Used
.isin()Used
.isna()and.notna()Assigned values with
.locAvoided chained assignment
Defined an analysis population
Completed the mini data-analysis challenge
Wrote a reusable filtering function
Validated filtered results
Committed Week 3 work to Git
Pushed Week 3 work to GitHub
What you should now understand¶
Raw tabular data
↓
DataFrame
↓
Inspect
↓
Select variables
↓
Define conditions
↓
Filter observations
↓
Validate
↓
Analysis-ready subsetNext week we will extend the wrangling workflow to joins, merges, concatenation, reshaping, and missing-data strategies.
Reference answer¶
1. DataFrames¶
A DataFrame provides labeled rows and columns plus tabular operations such as filtering, grouping, missing-value handling, joins, dtype management, and vectorized computation. A list of dictionaries can represent similar records, but does not provide these operations directly.
2. Series¶
df["age"]returns a one-dimensional Series.
df[["age"]]returns a one-column DataFrame.
3. .loc vs. .iloc¶
.loc selects using labels, while .iloc selects using integer positions.
4. Boolean filtering¶
df["score"] >= 85produces a Boolean Series containing one True or False value for each row.
5. Analysis populations¶
A filter is more than a programming operation because it defines which observations are included in the statistical analysis and therefore affects the scientific question being answered.
6. Validation¶
Code can run without errors while still implementing the wrong criterion. Inspecting the result helps detect logical errors.
7. Reproducibility¶
Explicit criteria make the inclusion/exclusion rules visible in code so another analyst can understand and recreate the same analysis population.