Project: Analyse a Dataset & Write a Report
In this project you will analyse students.csv from start to finish and write a short statistical report, the way an education officer might for a district. The file is synthetic (a program generated it), so nothing here is a finding about real schools; the point is the method. You will use almost every tool from the course: cleaning, frequency tables and charts, descriptive statistics, confidence intervals, t-tests, ANOVA, a chi-square test, correlation and multiple regression.
Each step is a separate runnable example. Because every Try-it run starts fresh, the steps repeat the same small load_students() function; in a notebook you would define it once at the top.
Step 1: the questions
A report without questions is just a pile of numbers. After talking to the (imaginary) district office, you agree on five:
- How are maths marks distributed, and what share of students pass (33 or more)?
- How precisely can we estimate the average mark and the pass rate from these students?
- Do marks differ by tuition, school type or gender?
- Do marks differ by city, and does passing depend on school type?
- Which factors go together with higher marks when considered jointly?
Write the questions down before analysing. Everything you test later should trace back to one of them, which is your protection against the p-hacking trap.
Step 2: inspect the raw data
Never compute anything before looking for problems. Check duplicates, inconsistent spellings, impossible values and missing cells.
import pandas as pd
raw = pd.read_csv("students.csv", na_values=["AB"])
print("Rows:", len(raw), " duplicate rows:", raw.duplicated().sum())
print("gender:", raw["gender"].value_counts().to_dict())
print("school_type:", raw["school_type"].value_counts().to_dict())
marks = ["maths", "science", "english", "hindi", "social_science"]
print("Marks above 100:", {c: int((raw[c] > 100).sum()) for c in marks if (raw[c] > 100).any()})
print("Attendance above 100:", int((raw["attendance"] > 100).sum()))
print("Missing:", raw.isna().sum()[raw.isna().sum() > 0].to_dict())Rows: 483 duplicate rows: 3
gender: {'M': 262, 'F': 218, 'f': 2, 'm': 1}
school_type: {'Government': 237, 'Private': 234, 'Private ': 3, 'government': 2, 'private': 2, 'PRIVATE': 2, 'Govt.': 2, 'Govt': 1}
Marks above 100: {'science': 1}
Attendance above 100: 1
Missing: {'attendance': 6, 'maths': 2, 'science': 6, 'hindi': 1}The file has every classic problem: 3 duplicate rows, lower-case genders, eight spellings of two school types, a science mark above 100, an attendance above 100% and a few missing marks (AB for absent). None of these would stop pandas from computing a mean; all of them would make the mean wrong.
Step 3: a cleaning function
Put the fixes in one function so every later step uses identical data. Impossible values become missing (NaN) rather than being guessed, and the report will say so.
import pandas as pd
def load_students():
df = pd.read_csv("students.csv", na_values=["AB"]).drop_duplicates()
df["gender"] = df["gender"].str.strip().str.upper()
school = df["school_type"].str.strip().str.lower().str.rstrip(".")
df["school_type"] = school.replace({"govt": "government"}).str.title()
for col in ["maths", "science", "english", "hindi", "social_science"]:
df[col] = df[col].where(df[col] <= 100) # impossible marks become missing
df["attendance"] = df["attendance"].where(df["attendance"] <= 100)
return df
students = load_students()
print("Rows after cleaning:", len(students))
print("gender:", students["gender"].value_counts().to_dict())
print("school_type:", students["school_type"].value_counts().to_dict())
print("Missing:", students.isna().sum()[students.isna().sum() > 0].to_dict())Rows after cleaning: 480
gender: {'M': 261, 'F': 219}
school_type: {'Government': 241, 'Private': 239}
Missing: {'attendance': 7, 'maths': 2, 'science': 7, 'hindi': 1}Series.where(condition) keeps values where the condition is true and replaces the rest with NaN, so the 110 in science and the 104% attendance are now missing. The dataset is 480 students, almost exactly half from each school type.
Step 4: describe the maths marks
Question 1 needs descriptive statistics, a frequency table and charts, from the Describing Data lessons.
import pandas as pd
import matplotlib.pyplot as plt
def load_students():
df = pd.read_csv("students.csv", na_values=["AB"]).drop_duplicates()
df["gender"] = df["gender"].str.strip().str.upper()
school = df["school_type"].str.strip().str.lower().str.rstrip(".")
df["school_type"] = school.replace({"govt": "government"}).str.title()
for col in ["maths", "science", "english", "hindi", "social_science"]:
df[col] = df[col].where(df[col] <= 100)
df["attendance"] = df["attendance"].where(df["attendance"] <= 100)
return df
students = load_students()
maths = students["maths"].dropna()
q1, q3 = maths.quantile([0.25, 0.75])
print(f"n = {len(maths)}, mean {maths.mean():.1f}, median {maths.median():.1f}, "
f"SD {maths.std():.1f}, IQR {q3 - q1:.1f}, skew {maths.skew():.2f}")
bands = pd.cut(maths, [0, 33, 60, 75, 101], right=False,
labels=["0-32 (fail)", "33-59", "60-74", "75-100"])
table = bands.value_counts(sort=False).to_frame("students")
table["percent"] = (table["students"] / len(maths) * 100).round(1)
print(table)
fig, axes = plt.subplots(1, 2, figsize=(10, 3.5))
axes[0].hist(maths, bins=range(15, 105, 5), edgecolor="white", color="#7c3aed")
axes[0].set_title("Maths marks")
groups = [students.loc[students["school_type"] == s, "maths"].dropna() for s in ["Government", "Private"]]
axes[1].boxplot(groups, tick_labels=["Government", "Private"])
axes[1].set_title("Maths marks by school type")
plt.show()n = 478, mean 58.1, median 58.0, SD 17.9, IQR 26.0, skew -0.03
students percent
maths
0-32 (fail) 38 7.9
33-59 214 44.8
60-74 144 30.1
75-100 82 17.2The marks are almost perfectly symmetric (skew -0.03, mean and median both 58), so the mean is a fair summary and normal-based methods are safe. The spread is wide: an SD of 18 marks and a middle half spanning 26 marks. About 8% of students fail and 17% score 75 or more. The box plot hints that private school students score a little higher; Step 6 tests that.
Step 5: estimate with confidence intervals
Question 2 is about precision. The 478 students are treated as a sample of a larger population of similar students, so the report gives confidence intervals, not bare numbers.
import numpy as np
import pandas as pd
from scipy import stats
def load_students():
df = pd.read_csv("students.csv", na_values=["AB"]).drop_duplicates()
df["gender"] = df["gender"].str.strip().str.upper()
school = df["school_type"].str.strip().str.lower().str.rstrip(".")
df["school_type"] = school.replace({"govt": "government"}).str.title()
for col in ["maths", "science", "english", "hindi", "social_science"]:
df[col] = df[col].where(df[col] <= 100)
df["attendance"] = df["attendance"].where(df["attendance"] <= 100)
return df
students = load_students()
maths = students["maths"].dropna()
n = len(maths)
ci = stats.t.interval(0.95, df=n - 1, loc=maths.mean(), scale=stats.sem(maths))
print(f"Mean maths mark: {maths.mean():.2f}, 95% CI {ci[0]:.2f} to {ci[1]:.2f}")
passed = int((maths >= 33).sum())
pci = stats.binomtest(passed, n).proportion_ci(method="wilson")
print(f"Pass rate: {passed}/{n} = {passed / n:.1%}, 95% CI {pci.low:.1%} to {pci.high:.1%}")Mean maths mark: 58.11, 95% CI 56.50 to 59.72 Pass rate: 440/478 = 92.1%, 95% CI 89.3% to 94.2%
With 478 students, the mean is pinned down to within about 1.6 marks either way, and the pass rate to within about 2.5 percentage points. These intervals go straight into the report.
Step 6: compare two groups at a time
Question 3 asks about tuition, school type and gender. Each is a two-group comparison, so a Welch t-test with a confidence interval and Cohen's d fits. Putting the comparison in a function keeps the three tests identical and the code short.
import numpy as np
import pandas as pd
from scipy import stats
def load_students():
df = pd.read_csv("students.csv", na_values=["AB"]).drop_duplicates()
df["gender"] = df["gender"].str.strip().str.upper()
school = df["school_type"].str.strip().str.lower().str.rstrip(".")
df["school_type"] = school.replace({"govt": "government"}).str.title()
for col in ["maths", "science", "english", "hindi", "social_science"]:
df[col] = df[col].where(df[col] <= 100)
df["attendance"] = df["attendance"].where(df["attendance"] <= 100)
return df
def compare(df, column, a, b):
x = df.loc[df[column] == a, "maths"].dropna()
y = df.loc[df[column] == b, "maths"].dropna()
res = stats.ttest_ind(x, y, equal_var=False)
ci = res.confidence_interval(0.95)
d = (x.mean() - y.mean()) / np.sqrt((x.var() + y.var()) / 2)
print(f"{a} ({len(x)}) {x.mean():.2f} vs {b} ({len(y)}) {y.mean():.2f}: "
f"diff {x.mean() - y.mean():+.2f}, 95% CI {ci.low:.2f} to {ci.high:.2f}, "
f"p = {res.pvalue:.4f}, d = {d:.2f}")
students = load_students()
compare(students, "tuition", "Yes", "No")
compare(students, "school_type", "Private", "Government")
compare(students, "gender", "F", "M")Yes (204) 58.79 vs No (274) 57.59: diff +1.20, 95% CI -2.01 to 4.41, p = 0.4637, d = 0.07 Private (238) 60.39 vs Government (240) 55.84: diff +4.55, 95% CI 1.35 to 7.74, p = 0.0053, d = 0.26 F (218) 59.22 vs M (260) 57.17: diff +2.05, 95% CI -1.20 to 5.29, p = 0.2154, d = 0.11
Only school type shows a clear difference: private school students average about 4.6 marks more (95% CI 1.4 to 7.7, p = 0.005), a small effect by Cohen's d (0.26). Tuition and gender differences are small and their intervals include zero. Three planned tests is fine; the report will say all three were planned and reported.
Remember what a t-test can and cannot say here. This is observational data: private school students may differ in family background, city or study habits, so the 4.6 marks is an association, not an effect of the school itself.
Step 7: several groups and two categories
Question 4 needs two more tools: ANOVA for five cities and a chi-square test for pass/fail against school type.
import pandas as pd
from scipy import stats
def load_students():
df = pd.read_csv("students.csv", na_values=["AB"]).drop_duplicates()
df["gender"] = df["gender"].str.strip().str.upper()
school = df["school_type"].str.strip().str.lower().str.rstrip(".")
df["school_type"] = school.replace({"govt": "government"}).str.title()
for col in ["maths", "science", "english", "hindi", "social_science"]:
df[col] = df[col].where(df[col] <= 100)
df["attendance"] = df["attendance"].where(df["attendance"] <= 100)
return df
students = load_students().dropna(subset=["maths"])
cities = [g["maths"] for _, g in students.groupby("city")]
anova = stats.f_oneway(*cities)
print(f"Cities: ANOVA F = {anova.statistic:.2f}, p = {anova.pvalue:.3f}; "
f"Kruskal-Wallis p = {stats.kruskal(*cities).pvalue:.3f}")
students["result"] = students["maths"].ge(33).map({True: "Pass", False: "Fail"})
table = pd.crosstab(students["school_type"], students["result"])
print(table)
chi = stats.chi2_contingency(table)
print(f"Chi-square = {chi.statistic:.2f}, p = {chi.pvalue:.4f}, smallest expected count {chi.expected_freq.min():.1f}")
print(pd.crosstab(students["school_type"], students["result"], normalize="index").round(3))Cities: ANOVA F = 0.44, p = 0.777; Kruskal-Wallis p = 0.871 result Fail Pass school_type Government 22 218 Private 16 222 Chi-square = 0.67, p = 0.4131, smallest expected count 18.9 result Fail Pass school_type Government 0.092 0.908 Private 0.067 0.933
The city averages do not differ beyond chance (p = 0.78, and the rank-based test agrees), so there is no need for post-hoc tests. The pass rate is 90.8% in government schools and 93.3% in private schools, a gap that a chi-square test cannot distinguish from chance (p = 0.41). So private schools have a higher average mark but not a detectably higher pass rate: a useful nuance for the report.
Step 8: what goes together with higher marks?
Question 5 is about several factors at once, which calls for correlation and multiple regression with dummy variables for tuition and school type.
import numpy as np
import pandas as pd
from scipy import stats
def load_students():
df = pd.read_csv("students.csv", na_values=["AB"]).drop_duplicates()
df["gender"] = df["gender"].str.strip().str.upper()
school = df["school_type"].str.strip().str.lower().str.rstrip(".")
df["school_type"] = school.replace({"govt": "government"}).str.title()
for col in ["maths", "science", "english", "hindi", "social_science"]:
df[col] = df[col].where(df[col] <= 100)
df["attendance"] = df["attendance"].where(df["attendance"] <= 100)
return df
data = load_students().dropna(subset=["maths", "study_hours", "attendance"])
for col in ["study_hours", "attendance"]:
r = stats.pearsonr(data[col], data["maths"])
print(f"r(maths, {col}) = {r.statistic:.3f}, p = {r.pvalue:.1e}")
names = ["study_hours", "attendance", "tuition_yes", "private"]
data = data.assign(tuition_yes=(data["tuition"] == "Yes").astype(int),
private=(data["school_type"] == "Private").astype(int))
X = np.column_stack([np.ones(len(data)), data[names]])
y = data["maths"].to_numpy()
coef, *_ = np.linalg.lstsq(X, y, rcond=None)
resid = y - X @ coef
df = len(y) - X.shape[1]
se = np.sqrt(np.diag((resid ** 2).sum() / df * np.linalg.inv(X.T @ X)))
p = 2 * stats.t.sf(np.abs(coef / se), df)
r2 = 1 - (resid ** 2).sum() / ((y - y.mean()) ** 2).sum()
table = pd.DataFrame({"coef": coef, "std_err": se, "p_value": p}, index=["intercept"] + names)
print(table.round(4))
print(f"n = {len(y)}, R^2 = {r2:.3f}")r(maths, study_hours) = 0.495, p = 1.6e-30
r(maths, attendance) = 0.388, p = 2.1e-18
coef std_err p_value
intercept -44.6954 8.1641 0.0000
study_hours 8.0979 0.6230 0.0000
attendance 0.9300 0.0941 0.0000
tuition_yes 1.5458 1.3614 0.2568
private 3.7258 1.3424 0.0057
n = 471, R^2 = 0.388Study hours (r = 0.50) and attendance (r = 0.39) both have moderate positive correlations with maths marks. In the joint model, each extra daily study hour goes with about 8.1 more marks and each attendance point with about 0.9 marks, holding the other factors fixed. Private schooling keeps a 3.7-mark association after adjusting for study and attendance, while tuition still shows nothing clear. The four factors together explain about 39% of the variation in marks, so most of what makes one student score higher than another is not in this file.
Step 9: write the report
A statistical report for non-statisticians is short, leads with answers, states uncertainty, and is honest about limits. One page is usually enough. Here is the skeleton, filled in from the steps above:
MATHS MARKS IN THE DISTRICT SAMPLE: A SHORT REPORT (practice data)
1. Summary
478 students with a maths mark were analysed after cleaning (3 duplicate rows removed,
8 spellings of school type unified, 2 impossible values set to missing).
The average mark is 58.1 (95% CI 56.5 to 59.7) and 92.1% of students pass
(95% CI 89.3% to 94.2%). Marks are symmetric with a wide spread (SD 17.9).
2. Group differences (all three comparisons planned in advance)
- Private vs government schools: +4.6 marks (95% CI 1.4 to 7.7, p = 0.005), a small effect.
Pass rates (93.3% vs 90.8%) do not differ beyond chance (p = 0.41).
- Tuition vs no tuition: +1.2 marks (95% CI -2.0 to 4.4, p = 0.46): no clear difference.
- Girls vs boys: +2.0 marks (95% CI -1.2 to 5.3, p = 0.22): no clear difference.
- Cities: no evidence of different averages (ANOVA p = 0.78).
3. Factors linked with higher marks (regression, n = 471, R^2 = 0.39)
Each extra hour of daily study: about +8 marks; each attendance point: about +0.9 marks,
holding other factors fixed. Private schooling keeps a +3.7 association; tuition does not.
4. Limits
Observational data: the associations are not proof of cause. Students were not randomly
assigned to schools or study habits, and unmeasured factors (family background, teacher
quality) may explain part of every difference. The file is synthetic practice data.
5. Recommendation
Study time and attendance are the factors most strongly linked with marks and the ones a
school can influence; a pilot programme with a control group would test whether
encouraging them actually raises marks.Notice the habits: every estimate has an interval, every test is reported whether or not it was significant, effect sizes appear next to p-values, and the limits section is not an afterthought. The recommendation points to an experiment, because that is the only way to turn an association into a cause.
Extend the project
- Repeat Steps 4 to 8 for
scienceorenglishand see whether the same patterns hold. - Add the city dummies to the regression (
pd.get_dummies(data["city"], drop_first=True)) and check whether the adjusted R² improves. - Use
weather.csvoremployees.csvfor a second report: for employees, does salary depend on department after adjusting for experience? - Bootstrap a confidence interval for the median maths mark and compare it with the t interval for the mean.
Key points
- Start from written questions, inspect the raw data and put every fix into one cleaning function.
- Describe before you test: shape, centre, spread and a frequency table tell you which methods are safe.
- Report estimates with confidence intervals, and tests with effect sizes and intervals, including the non-significant ones.
- Choose the test by design: Welch's t for two groups, ANOVA for several, chi-square for two categories, regression for joint effects.
- Observational data gives associations; say so, and suggest an experiment when a cause matters.
Exercise
Write the same kind of analysis for science marks: clean the data with load_students(), print the mean with its 95% CI, the pass rate (33 or more) with a Wilson interval, and the Welch t-test comparison of private and government schools with its confidence interval and Cohen's d. Finish with two sentences of report text.
Show solution
import numpy as np
import pandas as pd
from scipy import stats
def load_students():
df = pd.read_csv("students.csv", na_values=["AB"]).drop_duplicates()
df["gender"] = df["gender"].str.strip().str.upper()
school = df["school_type"].str.strip().str.lower().str.rstrip(".")
df["school_type"] = school.replace({"govt": "government"}).str.title()
for col in ["maths", "science", "english", "hindi", "social_science"]:
df[col] = df[col].where(df[col] <= 100)
df["attendance"] = df["attendance"].where(df["attendance"] <= 100)
return df
students = load_students()
sci = students["science"].dropna()
n = len(sci)
ci = stats.t.interval(0.95, df=n - 1, loc=sci.mean(), scale=stats.sem(sci))
print(f"Science: n = {n}, mean {sci.mean():.2f}, 95% CI {ci[0]:.2f} to {ci[1]:.2f}")
passed = int((sci >= 33).sum())
pci = stats.binomtest(passed, n).proportion_ci(method="wilson")
print(f"Pass rate {passed / n:.1%}, 95% CI {pci.low:.1%} to {pci.high:.1%}")
pvt = students.loc[students["school_type"] == "Private", "science"].dropna()
gov = students.loc[students["school_type"] == "Government", "science"].dropna()
res = stats.ttest_ind(pvt, gov, equal_var=False)
c = res.confidence_interval(0.95)
d = (pvt.mean() - gov.mean()) / np.sqrt((pvt.var() + gov.var()) / 2)
print(f"Private - Government: {pvt.mean() - gov.mean():+.2f}, 95% CI {c.low:.2f} to {c.high:.2f}, p = {res.pvalue:.4f}, d = {d:.2f}")
# Report: Science marks average about 59 (95% CI 57.6 to 60.5) and about 95% of students pass.
# Private school students score about 3 marks higher on average, but the evidence is borderline
# (p = 0.05, CI from 0 to 5.8 marks) and the effect is small; more data would be needed to be sure.Science: n = 473, mean 59.03, 95% CI 57.57 to 60.49 Pass rate 95.1%, 95% CI 92.8% to 96.7% Private - Government: +2.91, 95% CI -0.00 to 5.83, p = 0.0501, d = 0.18
Science looks similar to maths in its average and pass rate, but the private school gap is smaller (about 2.9 marks) and sits right on the 0.05 line, with a confidence interval that starts at zero. Report it honestly as borderline evidence of a small difference, not as a clear finding. The comment lines are the report text.
Quick Quiz
1. Why does the project define its five questions before any analysis?
Planned questions protect against p-hacking and make the report honest.
2. The cleaning function turns a science mark of 110 into NaN. Why not change it to 100 or to the mean?
An impossible value should be set to missing and reported, not replaced by a guess.
3. Private school students average 4.6 marks more (p = 0.005). What can the report claim?
Observational data shows association; d = 0.26 is a small effect and confounders are possible.
4. Which element must appear next to every estimate in the report?
Estimates without uncertainty mislead; intervals show how precise they are.
Learn Statistics with a mentor
Free lessons get you started. Our Data Science & Machine Learning training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
