← return to practice.dsc80.com
Instructor(s): Janine Tiefenbruck
This exam was administered in-person. The exam was closed-notes, except students were allowed to bring a single two-sided handwritten notes sheet. No calculators were allowed. Students had 80 minutes to take this exam.
Bike Anywhere Day is an annual San Diego event meant to encourage people to ride bikes.
On the day of the event, there are “pit stops” set up at various major intersections throughout the city where bikers can stop to get free t-shirts and snacks. Each pit stop is run by a volunteer “site captain” who is on site to record attendance at the pit stop.
We will assume that each biker participating in the event stops at exactly one pit stop.
In the DataFrame bike, we have collected information
about the pit stops for the most recent Bike Anywhere Day. The columns
are:
"location" (str): The corner at which the pit stop was
located. Always formatted in a specific way, as shown
in the preview below. We assume that all street names in San
Diego are unique."zip" (int): The zip code in which the pit stop was
located."neighborhood" (str): The neighborhood in which the pit
stop was located."site captain" (str): The last name and first initial
of the site captain for this pit stop."bikers" (int): The number of bikers who stopped at
this pit stop, as recorded by the site captain."t-shirts" (int): The number of t-shirts distributed at
this pit stop. Bikers may or may not take a shirt, but nobody can take
more than one shirt."snacks" (int): The number of snacks distributed at
this pit stop. Bikers can take as many snacks as they’d like.Note that the last three columns of bike may have
missing values, but the first four columns have
none.
The first three rows of bike are shown below, though
bike has many more.
Throughout the exam, assume that we have already run the
necessary import statements, including import pandas as pd
and import numpy as np.
Which feature types exist in bike? Select all that
apply.
Discrete.
Continuous.
Ordinal.
Nominal.
Answer: Discrete and Nominal.
Remember that a column’s feature type (what the
values mean) is not the same as its data type
(how pandas stores it).
"bikers",
"t-shirts", and "snacks" are all counts of
people or items. They’re numeric — it makes sense to add them and
average them — but they can only take on whole-number values, so they’re
discrete rather than continuous.bike is measured on that kind of
scale."small",
"medium", "large". None of the categorical
columns in bike have an inherent ranking."location",
"neighborhood", and "site captain" are all
unordered categories. So is "zip"! Zip codes are stored as
ints, but arithmetic on them is meaningless — you’d never
average two zip codes — which makes them nominal, just like phone
numbers or Social Security Numbers.
The average score on this problem was 93%.
Select all of the expressions below that perform a reasonable quality check of the data, which means both of the following are satisfied.
True, then the data is
as expected based on our domain knowledge of the context.False, it indicates that
there must be an error in the data.An example of a reasonable quality check is
(bike["zip"].astype(str).apply(len) == 5).all()because we know zip codes have length 5, and so any recorded zip code of a different length must be an error.
bike["site captain"].value_counts().iloc[0] == 1
(bike.dropna()["t-shirts"] <= bike.dropna()["bikers"]).sum() == bike.dropna().shape[0]
bike["snacks"].sum() >= bike["bikers"].sum()
bike["location"].str[0:2].apply(lambda s: s not in ["NE", "NW", "SE", "SW"]).sum() == 0
bike["location"].str.count("and").nunique() == 1
None of the above.
Answer: Options 2 and 4.
(bike.dropna()["t-shirts"] <= bike.dropna()["bikers"]).sum() == bike.dropna().shape[0]
bike["location"].str[0:2].apply(lambda s: s not in ["NE", "NW", "SE", "SW"]).sum() == 0
Recall the four pillars of data quality checks from Lecture 5 —
scope, measurements and values,
relationships, and analysis. A
reasonable check has to be an assertion that is guaranteed by the
context, not merely something we’d expect to be true. If a
plausible dataset can make the expression False without
anything being wrong, the check fails the second condition.
Option 1 ❌ —
bike["site captain"].value_counts().iloc[0] == 1. Since
value_counts() sorts in descending order,
.iloc[0] is the number of times the most frequent
site captain appears, so this asserts that no site captain name shows up
twice. Nothing in the setup requires that: one enthusiastic volunteer
could run two pit stops, and two different volunteers could easily share
a last name and first initial (there is more than one “Smith, J” in San
Diego). Not a valid check.
Option 2 ✅ —
(bike.dropna()["t-shirts"] <= bike.dropna()["bikers"]).sum() == bike.dropna().shape[0].
This is a relationships check: are related features in
agreement? Bikers may or may not take a shirt, but nobody can
take more than one, so the number of t-shirts handed out at a
pit stop can never exceed the number of bikers who stopped there.
Summing a boolean Series counts the Trues, so this asserts
that the inequality holds in every row. Any row where it fails
must be an error. (The dropna() matters — the comparison is
only meaningful on rows where both values were actually recorded, and
both sides use the same subset, so the indexes line up.)
Option 3 ❌ —
bike["snacks"].sum() >= bike["bikers"].sum(). Bikers can
take as many snacks as they’d like, which includes
zero. If most people grabbed a shirt and skipped the
snacks, total snacks would be legitimately below total bikers. The
expression evaluating to False tells us nothing is
necessarily wrong, so it’s not a valid check.
Option 4 ✅ —
bike["location"].str[0:2].apply(lambda s: s not in ["NE", "NW", "SE", "SW"]).sum() == 0.
This is a measurements and values check. Every
"location" is formatted like
"NE corner of Regents Road and Nobel Drive", so the first
two characters must be one of the four corner abbreviations. The lambda
returns True when a prefix is invalid, so the sum
counts invalid rows, and == 0 asserts there are none. A
False here means some location wasn’t recorded in the
required format.
Option 5 ❌ —
bike["location"].str.count("and").nunique() == 1. It’s
tempting to think each location contains exactly one "and"
— the delimiter between the two street names — but
.str.count counts substrings, not words. A
location like
"SE corner of Highland Avenue and Euclid Avenue" contains
"and" twice, once inside "Highland". The
number of matches can legitimately differ from row to row, so
False doesn’t imply an error.
The average score on this problem was 67%.
The "bikers" column is missing some values. Each part
below describes a possible explanation for this missingness. Assuming
that the given explanation is the only force at play,
determine
Site captains were provided with physical counting devices that they could use to keep track of attendance. Some of the devices were defective.
missing by design (MD)
missing not at random (MNAR)
missing at random (MAR)
missing completely at random (MCAR)
an unbiased estimate
an overestimate
an underestimate
Answer:
Walk down the flowchart from Lecture 7. It’s not MD — we can’t
recover the number of bikers from the other columns. It’s not MNAR — a
device breaking has nothing to do with how many bikers happened to show
up. And it’s not MAR — nothing in bike
("location", "zip",
"neighborhood", "site captain", …) tells us
anything about whether a captain’s device was defective. Which device
you got handed is essentially a coin flip, so the chance a value is
missing is independent of both the other columns and the missing value
itself: MCAR.
Because MCAR data is a random subset of all the data, the observed
"bikers" values are representative of all the
"bikers" values. Filling in the missing entries with the
mean of the observed ones therefore gives an unbiased
estimate of the true mean.
The average score on this problem was 67%.
Site captains were told about the requirement to keep track of attendance at neighborhood site captain meetings. The leader of the Mira Mesa neighborhood meeting forgot to inform the site captains of this requirement. Some Mira Mesa pit stops did record attendance without being told, but not all of them.
missing by design (MD)
missing not at random (MNAR)
missing at random (MAR)
missing completely at random (MCAR)
an unbiased estimate
an overestimate
an underestimate
Answer:
The chance that "bikers" is missing depends on the
"neighborhood" column: pit stops in Mira Mesa are much more
likely to be missing attendance than pit stops anywhere else. Since
"neighborhood" is a column we actually have, that’s the
definition of MAR — the missingness depends on other
columns, but not on the missing value itself. Notice it isn’t MD, since
we can’t determine the number of bikers from
"neighborhood"; some Mira Mesa pit stops recorded
attendance and some didn’t.
Part 2 was thrown out because the answer isn’t determined by the information given. (You can see this in the scoring: every other part of this problem split its 4 points evenly between the two questions, but here all 4 went to part 1.) Mean imputation on MAR data is biased if the group driving the missingness differs from the rest on the variable being imputed — but nothing here tells us whether Mira Mesa pit stops draw more, fewer, or about the same number of bikers as pit stops elsewhere in San Diego. If Mira Mesa is a typical neighborhood, the estimate happens to come out unbiased; if it’s busier or quieter than average, it doesn’t.
The fix, as in Lecture 8, would be to impute within groups — fill in each missing Mira Mesa value with the mean of the observed Mira Mesa values — which is unbiased for MAR data.
The average score on this problem was 66%.
Some sites were so busy that the site captains could not keep track of the number of bikers.
missing by design (MD)
missing not at random (MNAR)
missing at random (MAR)
missing completely at random (MCAR)
an unbiased estimate
an overestimate
an underestimate
Answer:
Here the chance that a value is missing depends on the value itself: the bigger the true number of bikers, the more likely the captain lost count. That’s MNAR (also called “non-ignorable” — the fact that a value is missing is itself informative).
Since it’s precisely the largest counts that go missing, the values we do observe are systematically too small. Filling the gaps with the mean of those observed values pulls the overall mean down, so mean imputation gives an underestimate of the true mean. This is the mirror image of the classic income-survey example from Lecture 7: when high earners decline to report, ignoring the missingness biases the mean salary low.
The average score on this problem was 69%.
After being informed of the requirement to keep track of attendance, site captains were never reminded. Older site captains were more likely to forget.
missing by design (MD)
missing not at random (MNAR)
missing at random (MAR)
missing completely at random (MCAR)
an unbiased estimate
an overestimate
an underestimate
Answer:
This one is worth reading carefully, because it looks like MAR at
first glance. The missingness does depend on a variable — the site
captain’s age — but that variable is not in
bike. The "site captain" column only
holds a last name and first initial, which tells us nothing about how
old someone is.
Recall the definition: data is MAR if the chance of being missing
depends on other columns, and MCAR if it’s independent
of the other columns and of the missing value. Relative to the
data we actually have, forgetting is unrelated to every column in
bike and unrelated to the number of bikers itself, so the
missingness is MCAR — and mean imputation gives an
unbiased estimate.
The lesson is that missingness mechanisms are a property of the
dataset in front of you, not of the world. If we went back and collected
a "captain age" column, the very same missingness would be
reclassified as MAR, and we could then impute within age groups.
Worth knowing: part 1 here was the hardest question on the entire exam, at 22% — most people saw “depends on age” and answered MAR without checking whether age was actually a column. Part 2 fared better at 46%. If you got this one wrong, you’re in good company, but it’s the single best part of this exam to go back and re-read.
The average score on this problem was 34%.
Note: This is a standalone question that does not
refer to the bike DataFrame.
Prior to 2023, Bike Anywhere Day was called Bike to Work Day. This
former name makes up the contents of the puzzle DataFrame
defined below.
puzzle = pd.DataFrame({1: ["bike", "work"],
0: ["to", "day"]},
index=[1, 0])Evaluate each of the following expressions.
puzzle.loc[1, 1]puzzle.iloc[1, 1]puzzle[1].iloc[1]puzzle.loc[1].loc[1]Answer:
"bike""day""work""bike"The trap in this question is that puzzle’s row labels
and column labels are the integers 1 and
0, in that order. So the label 1 and the
position 1 refer to different things everywhere, and
loc and iloc come apart.
puzzle.loc[1, 1] uses labels for both:
the row labeled 1 (the top row) and the column labeled
1 (the left column), giving "bike".puzzle.iloc[1, 1] uses positions for
both: row position 1 is the second row (the one labeled
0), and column position 1 is the second column
(the one labeled 0), giving "day".puzzle[1] uses [] on a DataFrame, which
selects a column by label — the column labeled
1, which is the Series ["bike", "work"] with
index [1, 0]. Then .iloc[1] takes the element
in position 1 of that Series, which is "work".puzzle.loc[1] selects the row labeled
1 as a Series, {1: "bike", 0: "to"}, whose
index is the column labels. Then .loc[1] grabs the entry
labeled 1, which is "bike".Note the contrast between 3 and 4: puzzle[1] gives a
column while puzzle.loc[1] gives a row, even though both
are indexed with 1.
(And if you read the four answers in the order the DataFrame displays
them — "bike", "to", "work",
"day" — you get the old name of the event.)
Scores fell steadily across the four, from 81% on the first to 60% on
the last, which is about what you’d expect: the plain
loc/iloc lookups are the warm-up, and the
chained ones are where confusing a label with a position actually costs
you.
The average score on this problem was 70%.
Determine the format of the output of each expression below.
puzzle[[1]]Series.
DataFrame with 2 rows and 2 columns.
DataFrame with 2 rows and 1 column.
DataFrame with 1 row and 2 columns.
None of the above.
puzzle.loc[puzzle.index <= 1]Series.
DataFrame with 2 rows and 2 columns.
DataFrame with 2 rows and 1 column.
DataFrame with 1 row and 2 columns.
None of the above.
Answer:
DataFrame with 2 rows and 1 column.
DataFrame with 2 rows and 2 columns.
Passing a list of column labels to
[] always returns a DataFrame, even when the list has just
one element. puzzle[[1]] keeps only the column labeled
1, so we get a DataFrame with both rows and one column.
Compare this to puzzle[1] from the previous part, which
passes a single label and returns a Series.
puzzle.index is [1, 0], so
puzzle.index <= 1 is the boolean array
[True, True] — both row labels are less
than or equal to 1. Passing that mask to .loc keeps every
row and every column, so the result is the entire DataFrame: 2 rows and
2 columns. It’s easy to see the <= 1 and assume it
filters something out, but the index here isn’t
[0, 1, 2, ...].
The average score on this problem was 64%.
Now we return to working with bike. Suppose you have
access to another DataFrame, last, which has the same
columns as bike except the data represents the pit stops
for last year’s Bike Anywhere Day. The
"bikers" column of last has no missing values,
because any data that was not collected last year has already been
imputed with an appropriate value.
Our goal in this problem is to use last year’s attendance records to fill in missing values in this year’s attendance records. The first step will be to merge the two DataFrames.
Every year, the set of pit stops for Bike Anywhere Day changes slightly. Pit stops can be removed, added, or relocated to different corners of the same intersection. For our analysis, we want to consider two pit stops the same if they involve the same two streets. These streets can be listed in either order, and we do not care which corner of the intersection is used. For example, we will consider both pit stops below to be equivalent.
"NE corner of Regents Road and Nobel Drive""SW corner of Nobel Drive and Regents Road"Fill in the implementation of the function add_join_key
which takes in a DataFrame formatted like bike or
last and returns a copy of that DataFrame with an
additional column called "join key" containing
string values. The format of the strings is up to you,
but the strings in the "join key" column must be
exactly the same for any two locations which we consider
equivalent (see above).
You may assume for this question that the string " and "
never appears within a street name, and so it can be used as a delimiter
between street names.
def add_join_key(df):
df = df.copy()
return dfAnswer:
def add_join_key(df):
df = df.copy()
def streets(s):
return str(sorted(s[13:].split(" and ")))
df["join key"] = df["location"].apply(streets)
return df
Two locations are equivalent when they name the same two streets, so the join key has to throw away everything that doesn’t matter and put what’s left in a canonical form. There are exactly two things to get rid of:
"NE corner of ",
"SW corner of ", and so on — so s[13:] slices
it off and leaves just "Regents Road and Nobel Drive"." and " gives a list of the two street names, and
sorted puts them into alphabetical order, so
"Regents Road and Nobel Drive" and
"Nobel Drive and Regents Road" both become
["Nobel Drive", "Regents Road"].Finally, the question asks for string values, so we
wrap the sorted list in str. Any canonical string works —
the point is that equivalent locations must produce identical strings. A
.str-based one-liner does the same job:
df["join key"] = df["location"].str[13:].str.split(" and ").apply(lambda pair: " & ".join(sorted(pair)))
Don’t forget df = df.copy() at the top (it’s already
given here) — without it, the function would modify the caller’s
DataFrame in place.
The average score on this problem was 51%.
Evaluate the expression below.
bike.pipe(add_join_key)["join key"].iloc[0]Answer:
"['Nobel Drive', 'Regents Road']"
df.pipe(f) is just another way of writing
f(df), so this adds the join key column to
bike and grabs its first entry. Row 0 of
bike has location
"NE corner of Regents Road and Nobel Drive", so we trace
through streets:
s[13:] →
"Regents Road and Nobel Drive".split(" and ") →
["Regents Road", "Nobel Drive"]sorted(...) →
["Nobel Drive", "Regents Road"] (alphabetically,
"N" comes before "R")str(...) →
"['Nobel Drive', 'Regents Road']"The answer is a string that happens to look like a
list — quotes, brackets, and all. That’s the whole point of the
str call: merge needs to compare hashable
values, and a list isn’t one.
The average score on this problem was 45%.
Fill in the blanks below so that merged has the same
rows as bike plus one additional column,
"prev", containing the number of bikers at that pit stop
last year.
merged = (last.rename(columns={"bikers": "prev"})
.pipe(add_join_key)
[___(a)___]
.merge(bike.pipe(add_join_key),
on="join key",
how=___(b)___))Answer:
["join key", "prev"]"right"(a) last has all seven of the same
columns as bike. If we merged the whole thing, every shared
column name would show up twice in the result with _x and
_y suffixes — a mess, and not “one additional column.” So
before merging we cut last down to only what we need: the
column we’re joining on, "join key", and the column we’re
after, "prev". Note the double brackets — passing a
list of labels to [] keeps a DataFrame.
(b) The left DataFrame is last and the
right is bike, and we want to keep every row of
bike, including this year’s brand-new pit stops
that have no match in last. That’s a right
join. The other options all fail:
"inner" would silently drop this year’s new pit
stops."left" would keep last year’s pit stops, including ones
that were removed this year."outer" keeps everything, so it would add rows for
removed pit stops that aren’t in bike at all.Rows of bike with no match in last get
NaN in "prev" — which is exactly the signal
the next part uses to tell new pit stops apart from old ones.
The average score on this problem was 54%.
Our strategy for filling in missing values in the
"bikers" column of merged will be a four-step
process described below. For each step, write the corresponding code to
achieve the desired result. All parts can be solved elegantly in
one line of code, but you can write more than one line if
needed.
growth."bikers" column by
multiplying the number of bikers last year by the median growth
ratio. This will fill in missing values for pit stops where
last year’s data is available."bikers"
column, use mean imputation to fill them in."bikers" column, truncate any decimal
portion so the column contains int values.Answer:
growth = (merged["bikers"] / merged["prev"]).median()
merged["bikers"] = merged["bikers"].fillna(merged["prev"] * growth)
merged["bikers"] = merged["bikers"].fillna(merged["bikers"].mean())
merged["bikers"] = merged["bikers"].astype(int)
Step 1. Dividing two Series lines them up row by
row, and any arithmetic involving NaN produces
NaN — so the ratio is automatically null exactly where
either year’s count is missing, just as the question describes.
.median() then skips those nulls by default, so no
dropna() is needed. The median is used rather than the mean
because a single pit stop that went from 2 bikers to 100 would blow up
the average growth ratio.
Step 2. fillna accepts a Series, and it
aligns on the index: each missing "bikers" entry is
replaced by the value in the corresponding row of
merged["prev"] * growth. Rows where "prev" is
itself NaN (the new pit stops) stay missing, which is what
step 3 is for.
Step 3. Plain mean imputation, which is what’s left
for pit stops that are new this year and so have nothing to scale from.
Note this uses the updated "bikers" column, so the
mean already includes the values filled in during step 2.
Step 4. Steps 2 and 3 introduced floats.
astype(int) truncates toward zero, which is exactly what
the question asks for. Be careful with alternatives:
.round() rounds to the nearest integer rather than
truncating, and np.floor leaves the column as floats.
The average score on this problem was 55%.
Finally, we recreate the bike DataFrame by dropping
unneeded columns from merged. We’ll also add a column to
indicate whether the pit stop is new from last year.
bike = (merged.drop(columns=["join key", "prev"])
.assign(new = merged["prev"].isna()))In this question, we handled the missing values in the
"bikers" column. We’ll assume, for the remainder of the
exam, that any missing values in other columns have also been filled in.
For the rest of the exam, bike has no missing
values.
Recall from the previous problem that
bike no longer has any missing values, and"new" column records whether a pit stop is new this
year, or existed last year (in which case, we’ll call it an old pit
stop). New pit stops have a value of True and old pit stops
have a value of False in the "new"
column.The first three rows of bike, with these modifications,
are shown below. Remember, there are many more rows!
Let piv be the pivot table defined as follows.
piv = bike.pivot_table(index="new",
columns="neighborhood",
values="bikers",
aggfunc="sum",
fill_value=0)Assuming that all values in the "bikers" column of
bike are integers greater than 0, which of the following
scenarios is possible? Select all that apply.
A column of piv has a 0 in the first row and a nonzero
value in the second row.
A column of piv has a 0 in the second row and a nonzero
value in the first row.
A column of piv has 0s in the first and second rows.
A column of piv has nonzero values in the first and
second rows.
Answer: Options 1, 2, and 4 — everything except “0s in the first and second rows.”
First, get oriented. piv has one row per value of
"new" and one column per neighborhood, and
False sorts before True, so the first
row is old pit stops and the second row is new pit
stops. Each cell is the total number of bikers at pit stops of
that kind in that neighborhood.
A 0 can only appear because of
fill_value=0: that combination of neighborhood and
"new" had no rows in bike at all. (It can’t
come from adding up actual counts, since every "bikers"
value is a positive integer.) So each option is really asking about
which combinations of pit stops a neighborhood can have.
0 in the first row means the neighborhood has
no old pit stops — every pit stop there is new this
year. Perfectly plausible for a neighborhood that just joined the
event.0 in the second row means the neighborhood got
no new pit stops this year — all of its pit stops
carried over from last year.piv
if it appears somewhere in bike, and every pit stop in
bike has a positive number of bikers. So at least one of
the two cells in the column must be nonzero.
The average score on this problem was 70%.
Using piv and without accessing bike or
merged, write one line of code that
evaluates to the probability that a biker visited a new pit stop, given
that they visited a pit stop in the Hillcrest neighborhood.
Answer:
piv.loc[True, "Hillcrest"] / piv["Hillcrest"].sum()
This is a conditional probability, so it’s a cell of the table divided by the total of the group we’re conditioning on:
P(\text{new} \mid \text{Hillcrest}) = \frac{\text{bikers at new Hillcrest pit stops}}{\text{bikers at all Hillcrest pit stops}}
Because each biker stops at exactly one pit stop,
adding up bikers is the same as counting people, so these totals really
do behave like counts of outcomes. The numerator is the cell in the
True row of the "Hillcrest" column, and the
denominator is the sum of that whole column.
Conditioning on Hillcrest means we only ever look at the
"Hillcrest" column — this is the same “divide by the column
sum to get a conditional distribution” move from Lecture 4. Note that
piv.loc[True, "Hillcrest"] uses the actual boolean
True as a label, not the string "True".
The average score on this problem was 51%.
Using piv and without accessing bike or
merged, write one line of code that
evaluates to a Series containing two values:
The Series should have the same index as piv.
Answer:
piv.max(axis=1)
We want one number per row of piv — the biggest
entry in the old row, and the biggest entry in the new row — so we
collapse across the columns. In pandas,
axis=1 means the operation runs along each row, one row at
a time, leaving the row index intact. That’s exactly the requirement
that the result have the same index as piv.
The common mistake here is piv.max(axis=0) (or just
piv.max(), since axis=0 is the default), which
gives the largest value in each column — one number per
neighborhood, indexed by neighborhood. A good sanity check when you’re
unsure which axis to use: ask what the index of the result should be,
and pick the axis that preserves it.
The average score on this problem was 31%.
Fill in the blanks below to create the same Series as the last part,
but this time using bike and not accessing
piv. Note that blanks (b) and (d)
are followed by an empty pair of parentheses; this limits what you can
put in those blanks.
(bike.groupby(___(a)___)
["bikers"]
.___(b)___()
.reset_index()
.groupby(___(c)___)
["bikers"]
.___(d)___()
)Answer:
["neighborhood", "new"]sum"new"maxThe strategy is to rebuild piv and then take the row
maximums, in two passes.
(a) and (b): grouping on both
columns and summing "bikers" gives the total number of
bikers for every (neighborhood, new) combination — the same
numbers that fill piv, just stacked into a Series with a
MultiIndex instead of spread across a grid. Either order
works, ["new", "neighborhood"] is equally fine, because the
next step flattens the index anyway.
.reset_index() then turns that Series into a DataFrame
with three ordinary columns: "neighborhood",
"new", and "bikers". This is the key step — it
promotes the MultiIndex levels back into columns so we can
group by one of them again.
(c) and (d): now group those per-neighborhood totals
by "new" alone and take the max of
"bikers". That’s the largest single-neighborhood total
among old pit stops and among new pit stops, indexed by
"new" — matching piv.max(axis=1) exactly.
The empty parentheses after (b) and (d) are
a hint: the blanks must be built-in aggregation methods like
sum and max that take no arguments, not
something like .agg("sum") or a lambda.
Watch out for the tempting shortcut of grouping only by
"new" and taking the max in one pass — that would give the
largest number of bikers at any single pit stop, not the
largest neighborhood total.
The average score on this problem was 56%.
Suppose that the number of visitors to old pit stops is greater than the number of visitors to new pit stops for each neighborhood individually. Is it possible that the number of visitors to old pit stops is less than or equal to the number of visitors to new pit stops for San Diego as a whole?
Yes, by Simpson’s Paradox.
Yes, for some other reason.
No.
Answer: No.
The citywide totals are nothing more than the sums of the neighborhood totals. If \text{old}_i > \text{new}_i in every neighborhood i, then adding those inequalities up gives
\sum_i \text{old}_i > \sum_i \text{new}_i
so old beats new citywide too. There’s no way around it.
The reason this isn’t Simpson’s paradox is that we’re comparing sums, not averages or rates. Simpson’s paradox happens because a group average is a weighted average of subgroup averages, and lopsided group sizes can flip the direction of the comparison — that’s what makes the Lecture 4 examples (batting averages, admission rates, the dog-weight tables on past exams) work. Sums have no weights to distort, so aggregation can’t reverse the inequality.
If the question had asked about the mean number of bikers per pit stop instead of the total, the answer would have been yes.
The average score on this problem was 22%.
Recall that bike has no missing values and contains a
"new" column indicating whether a pit stop is new this
year.
You want to test the following pair of hypotheses at the 0.05 significance level:
What kind of test should you perform?
A “standard” hypothesis test.
A permutation test.
Answer: A permutation test.
The rule of thumb from Lecture 6 is to ask what you’re comparing.
Here we have two groups of numbers pulled from the same dataset — the
"bikers" values at new pit stops and at old pit stops — and
nothing that tells us what “the” distribution of bikers per pit stop
should be. Under the null, the "new" label carries no
information about "bikers", so we can simulate by
shuffling one column relative to the other and
recomputing the statistic each time.
The average score on this problem was 59%.
You visualize the data in bike before deciding to use
the difference in mean bikers (new minus old) as your test statistic.
Assuming this is an appropriate choice, what does this say about the
data in bike? Choose the best answer below.
The data does not display any difference between the number of bikers at new and old pit stops.
The distribution of the number of bikers at new pit stops looks like a shifted version of the distribution of the number of bikers at old pit stops.
The distribution of the number of bikers at new pit stops is more varied than the distribution of the number of bikers at old pit stops.
There is a noticeable difference in the shapes of the two distributions.
Answer: The distribution of the number of bikers at new pit stops looks like a shifted version of the distribution of the number of bikers at old pit stops.
A difference in means only summarizes a difference between two distributions faithfully when the distributions have roughly the same shape and differ by a horizontal shift. That’s what makes “on average, one group is lower than the other” a fair description of what’s going on.
If the two distributions had genuinely different shapes, the difference in means would be a poor choice: as Lecture 8 shows, two very different distributions can have identical means, so a difference in means near zero would fail to detect a real difference. That’s precisely the situation where you’d reach for the Kolmogorov-Smirnov statistic instead.
The other options don’t work:
The average score on this problem was 66%.
Which of the following test statistics would also be appropriate to test these hypotheses? Select all that apply.
The absolute difference in mean bikers.
The total variation distance.
The Kolmogorov-Smirnov statistic.
None of the above.
Answer: None of the above.
The alternative hypothesis is directional — it doesn’t say new pit stops are different, it says they get fewer bikers. A valid test statistic for a one-sided alternative has to be one where large values point toward that specific direction. All three candidates throw the direction away:
"bikers" is a
numerical column, and TVD is both non-directional and inapplicable
here.
The average score on this problem was 61%.
The histogram below shows the distribution of simulated test statistics under the assumption of the null. If you are told that the p-value of your test comes out to 0.02, estimate the observed statistic. Round to the nearest integer.
Answer: -5
Start with which tail the p-value lives in. The statistic is the mean for new pit stops minus the mean for old pit stops, and the alternative says new pit stops get fewer bikers — so the alternative predicts a negative statistic, and “as or more extreme than the observed” means “less than or equal to the observed.” The p-value is therefore the area in the left tail:
(simulated_stats <= observed).mean()
So we need the value on the horizontal axis with about 2% of the histogram’s area to its left. The vertical axis is a probability density, so “area” means bar height times bin width, and the bars over the whole plot enclose a total area of 1.
The null distribution is centered near 0 with most of its area in the middle, so 2% puts us well out in the left tail. Sweeping in from the left edge and accumulating area as we go, the bars stay short until around -5, where the running total reaches roughly 2%; past that the bars grow quickly and the total climbs much faster. Reading off the axis and rounding to the nearest integer gives -5.
Two useful checks on your answer: it must be negative (a positive statistic would mean new pit stops did better, which could never give a small p-value here), and it must be out in the tail rather than near the bulk (a p-value of 0.02 is small, so the observation has to be rare under the null).
Since 0.02 < 0.05, we would reject the null hypothesis at the 0.05 significance level.
The average score on this problem was 48%.
As before, suppose the p-value comes out to 0.02. What do you conclude? Select all that apply.
New pit stops and old pit stops generally get the same number of bikers.
Old pit stops generally get more bikers than new pit stops.
New pit stops generally get more bikers than old pit stops.
Fewer people know about new pit stops, so attendance is less at new pit stops.
New pit stops are more heavily advertised, so attendance is greater there.
None of the above.
Answer: Old pit stops generally get more bikers than new pit stops.
Since the p-value of 0.02 is below the 0.05 significance level, we reject the null in favor of the alternative: our data are not consistent with new and old pit stops drawing the same number of bikers, and the direction of the alternative is that new pit stops draw fewer. Saying old pit stops get more is just the other way of saying that.
Every other option fails, for one of two reasons.
They accept the null, or contradict the direction. “New and old generally get the same number” is the null hypothesis, and we can never accept the null — the most we could ever have said is that we failed to reject it, and here we rejected it outright. “New pit stops generally get more bikers” points the opposite way from our alternative.
They claim a cause. Both the advertising options assert why the difference exists. A hypothesis test compares an observation against what the null predicts; it cannot tell you the mechanism behind a difference it detects. Fewer people knowing about new pit stops is a perfectly reasonable conjecture, but nothing in this test provides evidence for it over any other explanation, and the last option additionally contradicts the direction we found.
The average score on this problem was 80%.