Grading curve#
This course has a curve due to SIPA policy:
SIPA expects an average class GPA of 3.33 (B+) for core courses and those with enrollments exceeding 35 students. The acceptable range for such courses is 3.2 to 3.4.
As a compromise with Academic Affairs, the upper limit will be set to 3.6.
Updated on:
from datetime import date
date.today()
datetime.date(2026, 4, 14)
Ensure the visualizations render properly across VSCode, Jupyter Book, etc. You can ignore this.
import plotly.io as pio
pio.renderers.default = "colab+notebook_connected+plotly_mimetype"
Getting your estimated course grade#
Go to
Grades.View the current grade.
How course grades work#
Within the Grades section of CourseWorks: The Total course grade for each student is computed as a percentage, based on the weighted scores. It also shows an estimated letter grade.
A few times during the semester:
The full gradebook is exported and run through this notebook.
The percentage required for all the letter grades is adjusted up or down as necessary to hit the target GPA range.
Those cutoffs are updated in CourseWorks.
The estimated letter grades potentially change.
The min_score column of the new cutoffs shows the current minimum Total percentage required for each letter grade.
Methodology#
The rest of this notebook shows how the grade cutoffs are computed. This methodology, the distribution of student percentages, and thus your estimated course grade are subject to change up until final grades are submitted.
MIN_AVG_GPA = 3.2
MAX_AVG_GPA = 3.6
Load current scores#
The scores below are the total scores based on everything that has grades released thus far across both sections. See the timestamp in the filename below to know when it was updated. The grade data is anonymous for privacy reasons.
import pandas as pd
path = "/Users/afeld/Downloads/2026-04-04T1324_Grades-Python_for_Public_Policy,_Spring_2026.csv"
grades = pd.read_csv(path, skiprows=[1])
# exclude the test student built into CourseWorks
grades = grades[grades["Student"] != "Student, Test"]
# remove identifying information
grades = grades[
[
"Assignments Final Score",
"Exam (Secure Browser) (1632771)",
"Current Score",
]
]
grades = grades.sort_values("Current Score").reset_index(drop=True)
grades[["Current Score"]]
| Current Score | |
|---|---|
| 0 | 47.57 |
| 1 | 52.24 |
| 2 | 69.86 |
| 3 | 72.46 |
| 4 | 79.36 |
| ... | ... |
| 68 | 97.70 |
| 69 | 98.11 |
| 70 | 98.55 |
| 71 | 99.18 |
| 72 | 99.45 |
73 rows × 1 columns
Distribution#
import plotly.express as px
fig = px.histogram(
grades,
x="Current Score",
title="Distribution of the overall grades as a percentage, computed by CourseWorks",
labels={"Current Score": "Current Score (percent)"},
)
fig.update_layout(yaxis_title_text="Number of students")
fig.show()
Match to letter grades / GPAs#
Creating the Grading Scale in Pandas:
letter_grade_equivalents = pd.DataFrame(
index=["A", "A-", "B+", "B", "B-", "C+", "C", "C-", "D", "F"],
data={"gpa": [4.00, 3.67, 3.33, 3.00, 2.67, 2.33, 2.00, 1.67, 1.00, 0.00]},
)
Assign starting minimum scores, roughly based on the Default Canvas Grading Scheme:
letter_grade_equivalents["min_score"] = [
94.0,
90.0,
87.0,
84.0,
80.0,
77.0,
74.0,
70.0,
60.0,
0.0,
]
letter_grade_equivalents
| gpa | min_score | |
|---|---|---|
| A | 4.00 | 94.0 |
| A- | 3.67 | 90.0 |
| B+ | 3.33 | 87.0 |
| B | 3.00 | 84.0 |
| B- | 2.67 | 80.0 |
| C+ | 2.33 | 77.0 |
| C | 2.00 | 74.0 |
| C- | 1.67 | 70.0 |
| D | 1.00 | 60.0 |
| F | 0.00 | 0.0 |
Adjust cutoffs#
Raise or lower the minimum scores for each grade (not including F) until the average GPA is in the acceptable range.
# merge_asof() needs columns sorted ascending
orig_grade_cutoffs = letter_grade_equivalents.sort_values(by="min_score")
grade_cutoffs = orig_grade_cutoffs.copy()
grades_to_adjust = grade_cutoffs.index != "F"
adjustment = 0
STEP_SIZE = 0.1
while True:
grade_cutoffs.loc[grades_to_adjust, "min_score"] = (
orig_grade_cutoffs[grades_to_adjust]["min_score"] + adjustment
)
# make the letter grades a column so they show up in the merged DataFrame
grade_cutoffs_with_letters = grade_cutoffs.reset_index().rename(
columns={"index": "letter_grade"}
)
# find the letter grade / GPA for each student
adjusted_grades = pd.merge_asof(
grades,
grade_cutoffs_with_letters,
left_on="Current Score",
right_on="min_score",
direction="backward",
)
new_mean = adjusted_grades["gpa"].mean()
print(f"Adjustment: {adjustment:+.1f}, Average: {new_mean:.3f}")
# check if we've hit the target range
if MIN_AVG_GPA <= new_mean < MAX_AVG_GPA:
# success
break
elif new_mean >= MAX_AVG_GPA:
# raise
adjustment += STEP_SIZE
else: # new_mean < MIN_AVG_GPA:
# lower
adjustment -= STEP_SIZE
Adjustment: +0.0, Average: 3.512
Confirm the A cutoff is still achievable:
assert grade_cutoffs.at["A", "min_score"] <= 100
New cutoffs#
grade_cutoffs.sort_values("min_score", ascending=False)
| gpa | min_score | |
|---|---|---|
| A | 4.00 | 94.0 |
| A- | 3.67 | 90.0 |
| B+ | 3.33 | 87.0 |
| B | 3.00 | 84.0 |
| B- | 2.67 | 80.0 |
| C+ | 2.33 | 77.0 |
| C | 2.00 | 74.0 |
| C- | 1.67 | 70.0 |
| D | 1.00 | 60.0 |
| F | 0.00 | 0.0 |
These will be reflected in the CourseWorks grading scheme.
Check results#
Double-check the new average is in line with policy:
assert MIN_AVG_GPA <= new_mean < MAX_AVG_GPA, f"{new_mean} not in acceptable range"
new_mean
np.float64(3.512054794520548)
fig = px.histogram(adjusted_grades, x="letter_grade", title="Distribution of grades")
fig.update_layout(yaxis_title_text="Number of students")
fig.show()
Analysis#
Median exam score#
print(grades["Exam (Secure Browser) (1632771)"].median())
86.0
Assignment vs. exam scores#
fig = px.scatter(
grades,
x="Assignments Final Score",
y="Exam (Secure Browser) (1632771)",
range_y=[0, 100],
title="Relationship between assignment and exam scores",
labels={
"Assignments Final Score": "Assignments score",
"Exam (Secure Browser) (1632771)": "Exam score",
},
)
fig.update_traces(
marker=dict(
size=9,
opacity=0.6,
)
)
fig.show()
import plotly.graph_objects as go
fig = go.Figure()
fig.add_trace(
go.Histogram(
x=grades["Assignments Final Score"],
name="Assignments",
),
)
fig.add_trace(
go.Histogram(
x=grades["Exam (Secure Browser) (1632771)"],
name="Exam",
)
)
# Overlay both histograms
fig.update_layout(
title_text="Distributions of assignment vs exam scores",
barmode="overlay",
xaxis_title_text="Score",
yaxis_title_text="Count",
)
# Reduce opacity to see both histograms
fig.update_traces(opacity=0.75)
fig.show()