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.
Scores last updated¶
from datetime import date
date.today()datetime.date(2025, 12, 27)How course grades work¶
The grades are computed using the weights listed in the syllabus.
The percentage required for all the letter grades is adjusted up or down as necessary to hit the target GPA range across both sections.
The letter grades are uploaded to SSOL.
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.
MIN_AVG_GPA = 3.2
MAX_AVG_GPA = 3.4Load current scores¶
import pandas as pd
grades = pd.read_csv("/Users/afeld/Downloads/computing-in-context-fall-2025.csv")
grades = grades.sort_values("Final Grade")<frozen importlib._bootstrap>:491: RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module 'pandas._libs.pandas_parser', which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0.
Distribution¶
import plotly.io as pio
pio.renderers.default = "notebook_connected+plotly_mimetype"import plotly.express as px
fig = px.histogram(
grades,
x="Final Grade",
title="Distribution of the overall grades",
labels={"Final Grade": "Final Grade (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_equivalentsAdjust 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="Final Grade",
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_SIZEAdjustment: +0.0, Average: 3.519
Adjustment: +0.1, Average: 3.511
Adjustment: +0.2, Average: 3.511
Adjustment: +0.3, Average: 3.503
Adjustment: +0.4, Average: 3.499
Adjustment: +0.5, Average: 3.499
Adjustment: +0.6, Average: 3.499
Adjustment: +0.7, Average: 3.487
Adjustment: +0.8, Average: 3.480
Adjustment: +0.9, Average: 3.480
Adjustment: +1.0, Average: 3.480
Adjustment: +1.1, Average: 3.472
Adjustment: +1.2, Average: 3.460
Adjustment: +1.3, Average: 3.452
Adjustment: +1.4, Average: 3.444
Adjustment: +1.5, Average: 3.432
Adjustment: +1.6, Average: 3.424
Adjustment: +1.7, Average: 3.409
Adjustment: +1.8, Average: 3.405
Adjustment: +1.9, Average: 3.397
Confirm the A cutoff is still achievable:
assert grade_cutoffs.at["A", "min_score"] <= 100 # type: ignoreNew cutoffs¶
grade_cutoffs.sort_values("min_score", ascending=False)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_meannp.float64(3.3969411764705884)fig = px.histogram(adjusted_grades, x="letter_grade", title="Distribution of letter grades")
fig.update_layout(yaxis_title_text="Number of students")
fig.show()Export¶
Format needed by SSOL.
ssol = adjusted_grades.rename(columns={"SID": "UNI", "letter_grade": "grade"})
ssol = ssol[["UNI", "grade"]].sort_values("UNI")
ssol.to_csv("tmp/grades.csv", index=False)
print("Done")Done