Summer 2026 Midterm Exam

← return to practice.dsc10.com


Instructor(s): Janine Tiefenbruck

This exam was administered in-person. Students were allowed one page of double-sided handwritten notes. No calculators were allowed. The exam consisted of 8 questions worth 100 points total.


Note (groupby / pandas 2.0): Pandas 2.0+ no longer silently drops columns that can’t be aggregated after a groupby, so code written for older pandas may behave differently or raise errors. In these practice materials we use .get() to select the column(s) we want after .groupby(...).mean() (or other aggregations) so that our solutions run on current pandas. On real exams you will not be penalized for omitting .get() when the old behavior would have produced the same answer.


Unidentified flying objects (or UFOs) are objects seen in the sky that cannot be explained by the observer. These objects seem to be something other than planes, birds, etc. Some people believe that UFOs are actually extraterrestrial spacecrafts that suggest the possibility of alien life.

UFO sightings are reports describing where and when an individual observed a UFO. The DataFrame ufo has one row for every reported UFO sighting in the state of Nevada. The columns are:

The first few rows of ufo are shown below, though ufo has many more rows than pictured.


Throughout this exam, we will refer to ufo repeatedly. Assume that we have already run import babypandas as bpd and import numpy as np.


Problem 1


Problem 1.1

Complete the implementation of the function minutes which takes as input a single duration as a string and returns that duration in minutes, as an int. Example inputs and outputs are given below.

>>> minutes("22m")
22

>>> minutes("4m 45s")   # seconds do not contribute to the output
4

>>> minutes("1h 31m 2s")
91

Fill in blanks (i), (ii), and (iii).

def minutes(d):
    total = 0
    parts = __(i)__
    for part in parts:
        if part[-1] == "h":
            total = __(ii)__
        if part[-1] == "m":
            total = __(iii)__
    return total

Answer (i): d.split(" ")

Answer (ii): total + int(part.strip("h")) * 60

Answer (iii): total + int(part.strip("m"))

The pieces of a duration are separated by spaces, so d.split(" ") breaks the string into a list with one element per piece. For example, "1h 31m 2s" becomes ["1h", "31m", "2s"].

The for loop then looks at the last character of each piece to decide what unit it is measured in.

  • Blank (ii): If the piece ends in "h", it’s a number of hours. Strip off the "h", convert what’s left to an int, and multiply by 60 to convert hours to minutes. Add that to the running total.
  • Blank (iii): If the piece ends in "m", it’s already a number of minutes, so just strip the "m", convert to an int, and add it to the running total.

Notice there is no if statement for pieces ending in "s". That’s intentional: seconds do not contribute to the output, so those pieces are simply ignored.

Tracing through minutes("1h 31m 2s"): the piece "1h" contributes 1 \cdot 60 = 60, the piece "31m" contributes 31, and the piece "2s" contributes nothing, for a total of 91.


Problem 1.2

Complete the line of code to replace the "duration" column in the ufo DataFrame so that ufo.get(["duration"]) results in the DataFrame shown below (only the first few rows are shown). Utilize the minutes function you defined in part (a).


ufo = ufo.assign(__(i)__)

Answer (i): duration=ufo.get("duration").apply(minutes)

Assigning to a column name that already exists replaces that column, which is what we want here.

ufo.get("duration") is a Series of duration strings. Calling .apply(minutes) on that Series runs the minutes function on every element and returns a new Series of the corresponding integers. Note that we pass minutes without parentheses — we want to hand .apply the function itself, not call it.

Since the new column is named duration, we write it as the keyword argument duration=... inside .assign.


Problem 1.3

For the rest of the exam, we’ll assume that ufo contains the updated "duration" column.

The following four expressions are each intended to find the duration of the longest sighting in minutes. Select all that are correct.

Answer: Options 2 and 3.

  • Option 1: ufo.take(np.arange(5)) keeps only the first five rows, and .iloc[0] then grabs the duration of the very first row. There’s no reason the first row would hold the largest duration, so this is incorrect.
  • Option 2: .max() on the "duration" Series returns the largest duration directly. Correct.
  • Option 3: .sort_values("duration") sorts in ascending order by default, so the largest duration ends up in the last position, and .iloc[-1] retrieves it. Correct.
  • Option 4: Sorting in descending order does put the largest duration in the first position, but .loc[0] looks up the element with index label 0, not the element in position 0. Sorting rearranges the rows but carries the original index labels along, so .loc[0] still returns the duration of the row that was originally row 0. To make this work we’d need .iloc[0] instead.



Problem 2

Define the variables once and twice as follows.

once = ufo.groupby("zipcode").count()
twice = once.groupby("shape").count()


Problem 2.1

Determine the value resulting from the following expression. You may indicate that the value cannot be determined or the expression produces an error.

((once.get("duration") < 2) & (once.get("duration") >= 2)).sum()

Answer: 0

The two conditions being combined are exact opposites of one another: a number cannot be both strictly less than 2 and greater than or equal to 2 at the same time. Combining them with & therefore produces a Series containing False in every position, no matter what’s in once.

Summing a Boolean Series counts the number of True values, and since there are none, the result is 0. We don’t need to know anything about the contents of ufo to answer this.


Problem 2.2

What can we conclude using only the twice DataFrame? Select only one of the options and in no more than three sentences, explain your answer.

Answer: The number of zip codes where exactly 3 UFO sightings occurred.

Work from the inside out. once = ufo.groupby("zipcode").count() has one row per zip code, and every column of once holds the same number: the count of sightings in that zip code. In particular, the "shape" column of once is a number of sightings, not a shape.

So twice = once.groupby("shape").count() groups the zip codes by their number of sightings. Its index is a number of sightings and each entry counts how many zip codes had that many sightings. Looking up the row labeled 3 in twice tells us the number of zip codes where exactly 3 sightings occurred.

Here is the code that inspired the other options:

  • The zip codes where exactly 3 UFO sightings occurred: ufo.groupby("zipcode").count()
  • The number of sightings for each UFO shape where exactly 3 distinct zip codes were reported: ufo.groupby(["zipcode", "shape"]).count()
  • The number of zip codes where exactly 3 distinct UFO shapes were reported: ufo.groupby(["zipcode", "shape"]).count()
  • The number of sightings where the UFO shape was spherical: ufo.groupby("shape").count()



Problem 3

The "time" column in ufo describes the time at which a UFO was initially sighted. The times are formatted in 24-hour time which uses hours 00 through 23. For example, 13:51 is the same as 1:51 PM.

We want to create a function named time_of_day which takes in a time and returns the corresponding category, defined by the table below.

hour range category
00:00–04:59 "night"
05:00–11:59 "morning"
12:00–16:59 "afternoon"
17:00–23:59 "evening"

Example inputs and outputs are given below.

>>> time_of_day("00:00")
"night"

>>> time_of_day("13:37")
"afternoon"

>>> time_of_day("23:59")
"evening"


Problem 3.1

Consider the following implementations for time_of_day. Which of the following implementations are correct? Select all that apply.

Implementation 1:

def time_of_day(time):
    hour_part = int(time.split(":")[0])
    if hour_part >= 17 or hour_part < 24:
        return "evening"
    elif hour_part >= 12 or hour_part < 17:
        return "afternoon"
    elif hour_part >= 5 or hour_part < 12:
        return "morning"
    else:
        return "night"

Implementation 2:

def time_of_day(time):
    hour_part = int(time.split(":")[0])
    if hour_part < 5:
        return "night"
    if hour_part < 12:
        return "morning"
    if hour_part < 17:
        return "afternoon"
    if hour_part >= 0:
        return "evening"

Implementation 3:

def time_of_day(time):
    hour_part = int(time.split(":")[0])
    if hour_part >= 17:
        return "morning"
    elif hour_part >= 12:
        return "night"
    elif hour_part >= 5:
        return "afternoon"
    else:
        return "evening"

Answer: Implementation 2 only.

All three implementations extract the hour correctly with int(time.split(":")[0]); the difference is in the conditions.

  • Implementation 1: The conditions use or where they should use and. Every valid hour satisfies hour_part < 24, so the very first condition hour_part >= 17 or hour_part < 24 is True for every time, and the function always returns "evening".
  • Implementation 2: Correct. Because each if returns immediately, reaching the second if already guarantees hour_part >= 5, reaching the third guarantees hour_part >= 12, and reaching the fourth guarantees hour_part >= 17. So the one-sided comparisons are enough, and the categories come out exactly as specified. (The final if hour_part >= 0 is always True at that point, so it behaves like an else.)
  • Implementation 3: The structure is right, but the returned labels are shuffled. Hours 17 and later should be "evening", not "morning".


Problem 3.2

Suppose we use a correct implementation of time_of_day to add another column called "time_of_day" to the ufo DataFrame. Every entry in the "time" column has a converted value in the "time_of_day" column.


Which type of data visualization should be used to compare average duration to the original "time" column? Select only one of the options.

Answer: Line plot.

The "time" column is a sequence of times running from 00:00 to 23:59, so it behaves like a numerical variable with a natural order. Here we’re plotting one average duration per time, so we have one variable (average duration) measured across a sequence — exactly the situation a line plot is designed for. Line plots are the standard choice for showing how a quantity changes over time.

A scatter plot would be used for two numerical variables measured on each individual, a bar chart for a categorical variable, and a histogram for the distribution of a single numerical variable.


Problem 3.3

Which type of data visualization should be used to compare average duration to the new "time_of_day" column? Select only one of the options.

Answer: Bar chart.

Unlike "time", the "time_of_day" column is categorical — it takes only the four values "night", "morning", "afternoon", and "evening". Bar charts are used to show a numerical quantity (here, average duration) for each category of a categorical variable.

A line plot would incorrectly suggest a continuous trend between categories, while scatter plots and histograms both require numerical data.



Problem 4

Janine was in Las Vegas last year when she spotted a UFO. She remembers that the UFO was either disk-shaped or cylinder-shaped, but she does not remember which.

Write a Python expression that evaluates to the time at which this sighting could have occurred. If there are multiple possible sightings matching these conditions, your expression should evaluate to the time of the earliest one.

The zip code of Las Vegas is "89109". The earliest time is the one with the smallest hour value; ties are broken by the smallest minute value (i.e. "00:10" is earlier than "23:57", "04:10" is earlier than "04:11").

Hint: Since "time" is a string in "HH:MM" format, comparing these strings alphabetically gives the same result as comparing them chronologically.

You may write on multiple lines, but your code should represent a single expression in Python.

Answer:

ufo[(ufo.get("zipcode") == "89109") &
    ((ufo.get("shape") == "disk") |
     (ufo.get("shape") == "cylinder"))].get("time").min()

We build this up one piece at a time.

  • ufo.get("zipcode") == "89109" is a Boolean Series that is True for sightings in Las Vegas.
  • (ufo.get("shape") == "disk") | (ufo.get("shape") == "cylinder") is True for sightings of either shape Janine might have seen. We use | (or) here, not &, since a single sighting can’t be both shapes at once — requiring both would leave us with no rows at all.
  • Combining the two conditions with & keeps only the rows that satisfy both, and querying ufo with that Series gives the DataFrame of candidate sightings.

Note the parentheses around each individual comparison. They’re required, because & and | have higher precedence than == in Python.

Finally, .get("time") pulls out the times of those sightings and .min() finds the smallest one. Thanks to the hint, the alphabetically smallest "HH:MM" string is also the chronologically earliest time, so .min() is exactly what we want.

An equivalent approach sorts instead of using .min():

ufo[(ufo.get("zipcode") == "89109") &
    ((ufo.get("shape") == "disk") |
     (ufo.get("shape") == "cylinder"))].sort_values("time").get("time").iloc[0]

Problem 5

The density histogram below was created using data in the "duration" column of ufo.


Problem 5.1

Use the histogram above to determine the result of the following expression. You do not have to simplify your answer. Show your work.

ufo[(ufo.get("duration") >= 50) & (ufo.get("duration") < 150)].shape[0] / ufo.shape[0]

Answer: (0.004 \cdot 50) + (0.002 \cdot 50) = 0.30

The expression counts the sightings whose duration is at least 50 minutes and less than 150 minutes, then divides by the total number of sightings. That’s the proportion of sightings in the interval [50, 150).

In a density histogram, the area of a bar equals the proportion of values that fall in that bin. The interval [50, 150) is covered exactly by two bins:

  • The bin [50, 100) has height 0.004 and width 50, so its area is 0.004 \cdot 50 = 0.2.
  • The bin [100, 150) has height 0.002 and width 50, so its area is 0.002 \cdot 50 = 0.1.

Adding the two areas gives 0.2 + 0.1 = 0.30, so 30\% of sightings fall in this range.


Problem 5.2

We want a better look at shorter duration sightings. Consider a DataFrame short which only includes sightings that lasted less than an hour. We plot short with the following command:

short.plot(kind="hist", y="duration", bins=np.arange(0, 70, 10))

What can we conclude using only the new histogram and the number of rows in short? Select all that apply.

Answer: Options 3 and 4.

The bins are np.arange(0, 70, 10), which gives the edges 0, 10, 20, 30, 40, 50, 60 — that is, the bins [0, 10), [10, 20), [20, 30), [30, 40), [40, 50), and [50, 60]. A histogram tells us the proportion of values in each bin, and nothing about how the values are spread out within a bin. Multiplying a proportion by the number of rows in short converts it into a count.

  • Option 1: The shortest sighting falls somewhere in the leftmost non-empty bin, but the histogram doesn’t tell us where in that bin, so we can’t recover its exact duration.
  • Option 2: A single value like exactly 20 minutes sits inside the bin [20, 30), and the histogram doesn’t break that bin down any further.
  • Option 3: “Strictly less than 30 minutes” is exactly the first three bins, [0, 10), [10, 20), and [20, 30). Add their areas and multiply by the number of rows in short.
  • Option 4: “At least 40 and strictly less than 50” is precisely the bin [40, 50), so its area times the number of rows in short gives the count.
  • Option 5: short only contains sightings that lasted less than an hour, so sightings over an hour aren’t in this histogram at all. We’d need information about the full ufo DataFrame to answer this.

The general rule: a histogram can answer a question only when the interval in the question lines up exactly with bin boundaries.



Problem 6

The DataFrame farms contains information about eight Nevada farms. Each row includes the name of the farm as the index as well as the "zipcode", "town", and "state" of the farm. The DataFrame farms is shown below in its entirety.


The DataFrame rural only contains zip codes which appear both in ufo and farms. Each row in rural contains the number of "sightings" that occurred in ufo at a given zip code. The DataFrame rural is shown below in its entirety.


Problem 6.1

What does mystery1 evaluate to? Give your answer as an integer.

mystery1 = rural.merge(farms, left_index=True, right_on="zipcode").shape[0]

Answer: 5

This merge matches the index of rural (the zip codes 89101, 89301, and 89801) against the "zipcode" column of farms. A merge produces one row for every pair of matching rows, so we count the farms sitting in each of rural’s zip codes:

  • 89101: one farm (First Fruits) → 1 row
  • 89301: two farms (Rolling Horse, Ana’s Village) → 2 rows
  • 89801: two farms (Nevada Hay, Blue Lizard) → 2 rows

That gives 1 + 2 + 2 = 5 rows in total. The farms in 89406, 89446, and 89808 don’t appear because those zip codes aren’t in rural.


Problem 6.2

What does mystery2 evaluate to? Give your answer as an integer.

Hint: You do not need to know all of the rows in the ufo DataFrame.

mystery2 = ufo.merge(farms, on="zipcode").shape[0]

Answer: 17

This merge matches the "zipcode" column of ufo against the "zipcode" column of farms. Each sighting in a given zip code pairs with each farm in that same zip code, so the number of resulting rows is

\text{(sightings in that zip code)} \times \text{(farms in that zip code)}

summed over all zip codes that appear in both DataFrames.

This is exactly where rural comes in: it lists the zip codes appearing in both ufo and farms, along with the number of sightings in each. That’s why we don’t need to see all of ufo.

  • 89101: 1 sighting \times 1 farm = 1
  • 89301: 5 sightings \times 2 farms = 10
  • 89801: 3 sightings \times 2 farms = 6

Altogether, 1 + 10 + 6 = 17 rows.



Problem 7


Problem 7.1

Ray defines the probability of events regarding a single UFO sighting:

Recall a UFO may either be "cylinder", "sphere", or "disk".

S is the event in which a UFO is sphere-shaped. What is the maximum possible value of P(S)? Show your work.

Answer: 0.1

Every UFO is exactly one of the three shapes, so the three events are mutually exclusive and together cover every possibility:

P(C) + P(D) + P(S) = 1

Substituting P(C) = 0.3 and rearranging,

P(S) = 1 - 0.3 - P(D) = 0.7 - P(D)

To make P(S) as large as possible, we want P(D) as small as possible. We’re told D is at least twice as likely as C, so

P(D) \geq 2 \cdot 0.3 = 0.6

The smallest allowed value is P(D) = 0.6, which gives

P(S) = 0.7 - 0.6 = 0.1


Problem 7.2

Marta learns the following about her rural town:

What is the probability that a UFO is sighted and at least one crop circle is created on any given night? You do not have to simplify your answer. Show your work.

Answer: 0.2 \cdot (1 - 0.7^5)

We need both things to happen, so we multiply the probability of a sighting by the probability of at least one crop circle given that a sighting occurred.

The probability of a sighting is 0.2.

For the second piece, “at least one” is easiest to handle through its complement, which is “no crop circles at all.” Each farm independently avoids a crop circle with probability 1 - 0.3 = 0.7, and since the five farms are independent, the probability that none of them gets a crop circle is

0.7^5

So the probability that at least one crop circle is created, on a night when a UFO is sighted, is 1 - 0.7^5.

Multiplying the two pieces together gives

0.2 \cdot (1 - 0.7^5)



Problem 8

Lauren is a rancher in a small town. She observes that every time a UFO is sighted, 5 different cows go missing (however, they always eventually reappear). There are 100 cows in town, and Lauren owns 23 of the 100 cows.

Complete the simulation below to estimate the probability that exactly two of Lauren’s cows are abducted after a UFO sighting. Assume the missing cows are selected at random from the set of all cows.

Fill in blanks (i), (ii), (iii), and (iv).

all_cows = np.arange(100)

repetitions = 10000
count_event = 0

for i in np.arange(repetitions):
    abducted = np.random.choice(__(i)__)

    laurens_cows = abducted <= __(ii)__
    n = np.count_nonzero(laurens_cows)

    if __(iii)__:
        count_event = count_event + 1

prob = __(iv)__

Answer (i): all_cows, 5, replace=False

Answer (ii): 22

Answer (iii): n == 2

Answer (iv): count_event / repetitions

  • Blank (i): Each sighting takes 5 cows from the town’s 100 cows, so we draw 5 elements from all_cows. Since the problem says 5 different cows go missing, we must sample without replacement, hence replace=False.
  • Blank (ii): all_cows is np.arange(100), i.e. the numbers 0 through 99. Lauren owns 23 of them, and the natural way to label her cows is as the first 23 numbers, 0 through 22. So a cow belongs to Lauren exactly when its number is less than or equal to 22. The comparison abducted <= 22 produces a Boolean array, and np.count_nonzero counts the True values, so n is the number of Lauren’s cows that were abducted. Note that writing 23 here would incorrectly give Lauren 24 cows.
  • Blank (iii): We’re estimating the probability that exactly two of Lauren’s cows are taken, so the condition is n == 2. Writing n >= 2 would answer a different question.
  • Blank (iv): An empirical probability is the number of repetitions in which the event happened divided by the total number of repetitions.

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