EthicsComputerCourses
Join training +91 90532 10052

Project: Analyse a Dataset & Write a Report

Statistics · Lesson 28 of 28 Advanced 9 min read Updated

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:

  1. How are maths marks distributed, and what share of students pass (33 or more)?
  2. How precisely can we estimate the average mark and the pass rate from these students?
  3. Do marks differ by tuition, school type or gender?
  4. Do marks differ by city, and does passing depend on school type?
  5. 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.

PythonRun it yourself
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())
Output
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.

PythonRun it yourself
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())
Output
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.

PythonRun it yourself
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()
Output
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.2

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

PythonRun it yourself
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%}")
Output
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.

PythonRun it yourself
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")
Output
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.

PythonRun it yourself
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))
Output
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.

PythonRun it yourself
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}")
Output
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.388

Study 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:

Text
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 science or english and 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.csv or employees.csv for 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
Python
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.
Output
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?

2. The cleaning function turns a science mark of 110 into NaN. Why not change it to 100 or to the mean?

3. Private school students average 4.6 marks more (p = 0.005). What can the report claim?

4. Which element must appear next to every estimate in the report?

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