Winter 2026 Midterm Exam

← 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

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:

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.


Problem 1

Which feature types exist in bike? Select all that apply.

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).

  • Discrete: "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.
  • Continuous: none. A continuous feature is one that could take on any value in a range, like a height, a weight, or a duration. Nothing in bike is measured on that kind of scale.
  • Ordinal: none. An ordinal feature is categorical with a meaningful order, like "small", "medium", "large". None of the categorical columns in bike have an inherent ranking.
  • Nominal: "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.

Difficulty: ⭐️

The average score on this problem was 93%.


Problem 2

Select all of the expressions below that perform a reasonable quality check of the data, which means both of the following are satisfied.

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.

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.


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 67%.


Problem 3

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


Problem 3.1

Site captains were provided with physical counting devices that they could use to keep track of attendance. Some of the devices were defective.

  1. What is the most likely missingness mechanism?
  1. Mean imputation would give ________ of the mean.

Answer:

  1. missing completely at random (MCAR)
  2. an unbiased estimate

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.


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 67%.


Problem 3.2

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.

  1. What is the most likely missingness mechanism?
  1. Mean imputation would give ________ of the mean.

Answer:

  1. missing at random (MAR)
  2. This part was not graded — see below.

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.


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 66%.


Problem 3.3

Some sites were so busy that the site captains could not keep track of the number of bikers.

  1. What is the most likely missingness mechanism?
  1. Mean imputation would give ________ of the mean.

Answer:

  1. missing not at random (MNAR)
  2. an underestimate

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.


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 69%.


Problem 3.4

After being informed of the requirement to keep track of attendance, site captains were never reminded. Older site captains were more likely to forget.

  1. What is the most likely missingness mechanism?
  1. Mean imputation would give ________ of the mean.

Answer:

  1. missing completely at random (MCAR)
  2. an unbiased estimate

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.


Difficulty: ⭐️⭐️⭐️⭐️

The average score on this problem was 34%.



Problem 4

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])


Problem 4.1

Evaluate each of the following expressions.

  1. puzzle.loc[1, 1]
  2. puzzle.iloc[1, 1]
  3. puzzle[1].iloc[1]
  4. puzzle.loc[1].loc[1]

Answer:

  1. "bike"
  2. "day"
  3. "work"
  4. "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.

  1. 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".
  2. 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".
  3. 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".
  4. 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.


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 70%.


Problem 4.2

Determine the format of the output of each expression below.

  1. puzzle[[1]]
  1. puzzle.loc[puzzle.index <= 1]

Answer:

  1. DataFrame with 2 rows and 1 column.

  2. DataFrame with 2 rows and 2 columns.

  3. 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.

  4. 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, ...].


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 64%.



Problem 5

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.


Problem 5.1

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 df

Answer:

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:

  • The corner. Every location starts with the same 13-character prefix pattern — "NE corner of ", "SW corner of ", and so on — so s[13:] slices it off and leaves just "Regents Road and Nobel Drive".
  • The order of the streets. Splitting on " 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.


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 51%.


Problem 5.2

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.


Difficulty: ⭐️⭐️⭐️⭐️

The average score on this problem was 45%.


Problem 5.3

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:

  • (a): ["join key", "prev"]
  • (b): "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.


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 54%.


Problem 5.4

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.

  1. Compute the ratio of this year’s bikers to last year’s bikers for each pit stop. This ratio will be null if we do not have participation data for both years. Determine the median ratio, which represents a typical amount of growth from one year to the next, and store it as growth.
  2. Fill in missing values in the "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.
  3. If there are still missing values in the "bikers" column, use mean imputation to fill them in.
  4. Since we may have introduced non-integer values into the "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.


Difficulty: ⭐️⭐️⭐️

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.


Problem 6

Recall from the previous problem that

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)


Problem 6.1

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.

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.

  1. ✅ A 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.
  2. ✅ A 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.
  3. ❌ Impossible. A neighborhood only gets a column in 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.
  4. ✅ The most ordinary case of all: the neighborhood has both old and new pit stops.

Difficulty: ⭐️⭐️⭐️

The average score on this problem was 70%.


Problem 6.2

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".


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 51%.


Problem 6.3

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.


Difficulty: ⭐️⭐️⭐️⭐️

The average score on this problem was 31%.


Problem 6.4

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:

  • (a): ["neighborhood", "new"]
  • (b): sum
  • (c): "new"
  • (d): max

The 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.


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 56%.


Problem 6.5

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?

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.


Difficulty: ⭐️⭐️⭐️⭐️⭐️

The average score on this problem was 22%.



Problem 7

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:


Problem 7.1

What kind of test should you perform?

Answer: A permutation test.

The rule of thumb from Lecture 6 is to ask what you’re comparing.

  • In a standard hypothesis test, you have one sample and a fully specified model of how the data was generated under the null, so you can simulate directly from that model — like flipping a fair coin 100 times, or drawing from a known population distribution.
  • In a permutation test, you have two samples and no information about any population, and you’re asking whether they look like they came from the same underlying distribution.

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.


Difficulty: ⭐️⭐️⭐️

The average score on this problem was 59%.


Problem 7.2

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.

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:

  • If the data showed no difference at all, there’d be no point running the test.
  • A difference in spread with the same center is exactly what a difference in means can’t see.
  • A noticeable difference in shapes is an argument against using the difference in means.

Difficulty: ⭐️⭐️⭐️

The average score on this problem was 66%.


Problem 7.3

Which of the following test statistics would also be appropriate to test these hypotheses? Select all that apply.

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:

  • Absolute difference in mean bikers. Taking the absolute value collapses “new got far fewer” and “new got far more” into the same large value, so a big statistic no longer means what the alternative claims. It’s the right statistic for the two-sided alternative (“the numbers are different”), not this one.
  • Total variation distance. The TVD compares two categorical distributions — it’s a sum of absolute differences in proportions across categories. "bikers" is a numerical column, and TVD is both non-directional and inapplicable here.
  • Kolmogorov-Smirnov statistic. The K-S statistic is the largest vertical gap between two CDFs. It works on numerical data, but like the TVD it’s a distance: it detects any difference in distribution and is never negative, so it can’t distinguish “fewer” from “more.”

Difficulty: ⭐️⭐️⭐️

The average score on this problem was 61%.


Problem 7.4

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.


Difficulty: ⭐️⭐️⭐️⭐️

The average score on this problem was 48%.


Problem 7.5

As before, suppose the p-value comes out to 0.02. What do you conclude? Select all that apply.

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.


Difficulty: ⭐️⭐️

The average score on this problem was 80%.



👋 Feedback: Find an error? Still confused? Have a suggestion? Let us know here.