List 7 — Data Analysis
Descriptive statistics, distributions in data, correlation
Biased Die: Empirical vs Theoretical Distribution
A six-sided die is suspected to be biased. Its true probabilities are . Roll it times, record results, compute empirical relative frequencies, and compare them to the theoretical probabilities. Discuss the chi-square intuition.
- a)Generate a dataset of 600 rolls using the given probability vector.
- b)Compute the empirical relative frequency of each face.
- c)Compare empirical frequencies to theoretical probabilities in a table.
- d)Compute the chi-square statistic and interpret it.
- e)Use the live simulator below to watch convergence as grows.
Use NumPy to simulate 600 rolls of a biased die. Each row stores a single roll outcome (1–6).
import numpy as np
import pandas as pd
np.random.seed(42)
probs = [0.14, 0.16, 0.17, 0.18, 0.16, 0.19]
faces = [1, 2, 3, 4, 5, 6]
rolls = np.random.choice(faces, size=600, p=probs)
df = pd.DataFrame({'roll': rolls})
counts = df['roll'].value_counts().sort_index()
df_summary = pd.DataFrame({
'face': faces,
'observed': counts.values,
'empirical_freq': (counts.values / 600).round(4),
'theoretical_prob': probs
})
print(df_summary.to_string(index=False))For each face , the empirical relative frequency is . With rolls the law of large numbers guarantees .
The expected count for face is . Compute the chi-square statistic and compare to the critical value .
import numpy as np
from scipy.stats import chisquare
probs = [0.14, 0.16, 0.17, 0.18, 0.16, 0.19]
n = 600
expected = [p * n for p in probs]
np.random.seed(42)
faces = [1, 2, 3, 4, 5, 6]
rolls = np.random.choice(faces, size=n, p=probs)
observed = [np.sum(rolls == k) for k in faces]
chi2, p_val = chisquare(observed, f_exp=expected)
print(f"Observed: {observed}")
print(f"Expected: {expected}")
print(f"Chi-square: {chi2:.4f}, p-value: {p_val:.4f}")
print("Conclusion: small chi2 => empirical matches theoretical.")Use the die simulator below. Increase the number of rolls and observe how the bar of each face converges toward its theoretical probability. This illustrates the Law of Large Numbers.
Biased Coin: Law of Large Numbers in Action
A coin has . Flip it times and track the running relative frequency of heads. Observe how the empirical proportion converges to the true probability as increases.
- a)Simulate 1000 coin flips with .
- b)Compute the running (cumulative) relative frequency after each flip.
- c)At what does the running frequency stay within of 0.52?
- d)Use the live LLN simulator below to explore convergence interactively.
Encode heads as 1, tails as 0. The running mean after flips is the empirical estimate of .
import numpy as np
import pandas as pd
np.random.seed(7)
flips = np.random.binomial(1, 0.52, size=1000)
running_freq = np.cumsum(flips) / np.arange(1, 1001)
df = pd.DataFrame({
'flip_number': range(1, 1001),
'outcome': flips,
'running_freq': running_freq.round(4)
})
print(df.head(10).to_string(index=False))
print("...")
print(df.tail(5).to_string(index=False))By the (weak) Law of Large Numbers, for any : as the probability that goes to 0.
Find the smallest after which the running frequency stays within of the true probability for the remaining flips.
import numpy as np
np.random.seed(7)
flips = np.random.binomial(1, 0.52, size=1000)
running_freq = np.cumsum(flips) / np.arange(1, 1001)
eps = 0.02
within = np.abs(running_freq - 0.52) <= eps
stable_n = None
for i in range(len(within)):
if np.all(within[i:]):
stable_n = i + 1
break
print(f"Stable within +-{eps} of 0.52 from flip n={stable_n}")
print(f"Final running frequency: {running_freq[-1]:.4f}")Use the simulator below to flip the coin thousands of times interactively. Watch how the running relative frequency approaches the red line at .
Relative frequency of heads converges to p = 0.52 as the number of flips grows.
Exam Scores: Comparing Two Groups
Two teaching groups took the same exam. Group A scores follow and Group B scores follow , both with students. Compare the distributions on mean, median, standard deviation, and pass rate ().
- a)Generate exam scores for both groups.
- b)Compute mean, median, and standard deviation for each group.
- c)Compute the pass rate () for each group.
- d)Explain why Group B has a higher mean yet a lower pass rate is possible in some simulations.
Simulate exam scores. Scores are clipped to [0, 100] since the exam is out of 100 marks.
import numpy as np
import pandas as pd
np.random.seed(21)
scores_a = np.clip(np.random.normal(68, 12, 200), 0, 100).round(1)
scores_b = np.clip(np.random.normal(74, 18, 200), 0, 100).round(1)
df = pd.DataFrame({
'student': list(range(1, 201)) * 2,
'group': ['A'] * 200 + ['B'] * 200,
'score': np.concatenate([scores_a, scores_b])
})
print(df.head(5).to_string(index=False))
print(df.groupby('group')['score'].describe().round(2))Compute the core descriptive statistics. Sample mean and sample standard deviation use the unbiased estimators.
import numpy as np
np.random.seed(21)
scores_a = np.clip(np.random.normal(68, 12, 200), 0, 100)
scores_b = np.clip(np.random.normal(74, 18, 200), 0, 100)
for label, scores in [('A', scores_a), ('B', scores_b)]:
print(f"Group {label}: mean={scores.mean():.2f}, "
f"median={np.median(scores):.2f}, "
f"std={scores.std(ddof=1):.2f}, "
f"pass_rate={(scores >= 60).mean()*100:.1f}%")Group B has a higher theoretical mean (74 vs 68) but also much greater spread ( vs ). More Group B students score both very high and very low. The heavier lower tail can erode Group B's pass rate despite its higher mean.
Delivery Times: Outliers and Robust Statistics
Delivery times (in minutes) follow a lognormal distribution with . Five outliers representing "lost packages" (times 300–500 min) are injected. Detect outliers using the rule and compare mean/median and std/IQR before and after removal.
- a)Generate 200 delivery times from the lognormal distribution and inject 5 outliers.
- b)Compute Q1, Q3, IQR, and the outlier fences.
- c)Identify outliers using the rule.
- d)Compare mean vs median and std vs IQR with and without outliers.
The lognormal distribution is right-skewed — a natural model for delivery times. We inject five extreme values to represent failed deliveries.
import numpy as np
import pandas as pd
np.random.seed(33)
normal_times = np.random.lognormal(mean=3.5, sigma=0.4, size=200)
outlier_times = np.random.uniform(300, 500, size=5)
times = np.concatenate([normal_times, outlier_times])
np.random.shuffle(times)
df = pd.DataFrame({'delivery_time': times.round(2)})
print(df['delivery_time'].describe().round(2))
print(f"Total records: {len(df)}")The IQR rule flags any value below or above as a potential outlier.
import numpy as np
np.random.seed(33)
times = np.concatenate([
np.random.lognormal(3.5, 0.4, 200),
np.random.uniform(300, 500, 5)
])
q1, q3 = np.percentile(times, [25, 75])
iqr = q3 - q1
lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
outliers = times[(times < lower) | (times > upper)]
print(f"Q1={q1:.2f}, Q3={q3:.2f}, IQR={iqr:.2f}")
print(f"Fences: [{lower:.2f}, {upper:.2f}]")
print(f"Outliers detected: {len(outliers)}")
print(f"Outlier values: {sorted(outliers.round(1))}")Outliers inflate the mean and standard deviation dramatically. The median and IQR are resistant to extreme values.
import numpy as np
np.random.seed(33)
times = np.concatenate([
np.random.lognormal(3.5, 0.4, 200),
np.random.uniform(300, 500, 5)
])
q1, q3 = np.percentile(times, [25, 75])
iqr = q3 - q1
clean = times[(times >= q1 - 1.5*iqr) & (times <= q3 + 1.5*iqr)]
print("With outliers: mean={:.1f}, median={:.1f}, std={:.1f}, IQR={:.1f}".format(
times.mean(), np.median(times), times.std(ddof=1), iqr))
print("Without outliers: mean={:.1f}, median={:.1f}, std={:.1f}, IQR={:.1f}".format(
clean.mean(), np.median(clean), clean.std(ddof=1),
np.percentile(clean,75)-np.percentile(clean,25)))The 5 injected outliers raise the mean by roughly 30–50 minutes but barely shift the median. IQR is similarly stable. Use median and IQR for skewed or outlier-prone data; reserve mean and std for roughly symmetric distributions.
Customer Survey: Conditional Frequencies and Cross-Tabs
A customer survey records: age group (18–30, 31–50, 51+), acquisition channel (Online, Store, Referral), satisfaction score (1–5), and renewal flag (Yes/No). Analyze renewal rates by channel and satisfaction level.
- a)Generate 500 synthetic survey records.
- b)Build a cross-tabulation of channel vs renewal.
- c)Compute conditional renewal rate by satisfaction score.
- d)Identify which channel and satisfaction combination has the highest renewal rate.
Higher satisfaction scores are linked to higher renewal probability. Referral customers have a slightly better renewal baseline.
import numpy as np
import pandas as pd
np.random.seed(55)
n = 500
channels = np.random.choice(['Online','Store','Referral'], n, p=[0.5,0.3,0.2])
ages = np.random.choice(['18-30','31-50','51+'], n, p=[0.35,0.45,0.20])
satisfaction = np.random.choice([1,2,3,4,5], n, p=[0.08,0.12,0.25,0.35,0.20])
renew_prob = 0.3 + 0.12 * (satisfaction - 1)
renew_prob += np.where(channels == 'Referral', 0.05, 0)
renewal = (np.random.rand(n) < renew_prob).astype(int)
df = pd.DataFrame({'age':ages,'channel':channels,
'satisfaction':satisfaction,'renewal':renewal})
print(df.head(8).to_string(index=False))The cross-tab shows absolute counts. Dividing each row by its total gives the conditional renewal rate per channel.
import numpy as np
import pandas as pd
np.random.seed(55)
n = 500
channels = np.random.choice(['Online','Store','Referral'], n, p=[0.5,0.3,0.2])
satisfaction = np.random.choice([1,2,3,4,5], n, p=[0.08,0.12,0.25,0.35,0.20])
renew_prob = 0.3 + 0.12*(satisfaction-1)
renew_prob += np.where(channels=='Referral', 0.05, 0)
renewal = (np.random.rand(n) < renew_prob).astype(int)
df = pd.DataFrame({'channel':channels,'satisfaction':satisfaction,'renewal':renewal})
crosstab = pd.crosstab(df['channel'], df['renewal'],
margins=True, margins_name='Total')
print(crosstab)
rate = df.groupby('channel')['renewal'].mean().round(3)
print("
Renewal rate by channel:")
print(rate)Conditional renewal rate: . This reveals the monotone relationship between satisfaction and loyalty.
import numpy as np
import pandas as pd
np.random.seed(55)
n = 500
channels = np.random.choice(['Online','Store','Referral'], n, p=[0.5,0.3,0.2])
satisfaction = np.random.choice([1,2,3,4,5], n, p=[0.08,0.12,0.25,0.35,0.20])
renew_prob = 0.3 + 0.12*(satisfaction-1)
renew_prob += np.where(channels=='Referral', 0.05, 0)
renewal = (np.random.rand(n) < renew_prob).astype(int)
df = pd.DataFrame({'channel':channels,'satisfaction':satisfaction,'renewal':renewal})
rate_s = df.groupby('satisfaction')['renewal'].mean().round(3)
print("Renewal rate by satisfaction:")
print(rate_s)
best = df.groupby(['channel','satisfaction'])['renewal'].mean().idxmax()
print(f"Highest renewal: channel={best[0]}, satisfaction={best[1]}")Factory Measurements: Machine Comparison and Spec Limits
Three factory machines (M1, M2, M3) produce parts with a target diameter of . Specification limits are . Each machine produces 150 parts. Compare mean offset from target, standard deviation, and fraction within spec.
- a)Generate measurements: M1 ~ N(50.1, 0.3²), M2 ~ N(49.8, 0.6²), M3 ~ N(50.0, 0.2²).
- b)Compute mean, std, and bias (mean − 50) for each machine.
- c)Compute the fraction of parts within spec [49, 51].
- d)Rank the machines by quality; explain the bias-variance trade-off.
M1 is slightly high-biased but precise. M2 is low-biased and imprecise. M3 is centred and most precise.
import numpy as np
import pandas as pd
np.random.seed(99)
m1 = np.random.normal(50.1, 0.3, 150)
m2 = np.random.normal(49.8, 0.6, 150)
m3 = np.random.normal(50.0, 0.2, 150)
df = pd.DataFrame({
'measurement': np.concatenate([m1, m2, m3]),
'machine': ['M1']*150 + ['M2']*150 + ['M3']*150
})
print(df.groupby('machine')['measurement'].describe().round(4))Bias measures systematic error (offset from target). Variance measures random error (scatter around the machine mean).
import numpy as np
np.random.seed(99)
machines = {
'M1': np.random.normal(50.1, 0.3, 150),
'M2': np.random.normal(49.8, 0.6, 150),
'M3': np.random.normal(50.0, 0.2, 150),
}
for name, data in machines.items():
bias = data.mean() - 50
std = data.std(ddof=1)
in_spec = ((data >= 49) & (data <= 51)).mean()
print(f"{name}: bias={bias:+.4f} mm, std={std:.4f} mm, "
f"within_spec={in_spec*100:.1f}%")M3 dominates: it is centred on target and has the smallest spread, yielding the highest fraction within spec. M2 is worst due to combined bias and high variance. M1 has small bias but is less precise than M3.
Call-Center Requests: Poisson Distribution by Hour
A call-center logs hourly request counts over 60 days. The arrival rate varies by time of day: (off-peak), (peak 10–12, 14–16), (shoulder 8–10, 12–14, 16–18). Verify that empirical mean variance (Poisson property).
- a)Generate 60 days × 10 hours of Poisson-distributed counts.
- b)Aggregate to get the empirical mean and variance per hour.
- c)Verify mean ≈ variance for each hour band (Poisson property).
- d)Identify the peak hour and compare its distribution to the off-peak hour.
Each cell is an independent Poisson draw. The resulting dataset has 600 rows (day × hour).
import numpy as np
import pandas as pd
np.random.seed(77)
hours = list(range(8, 18))
lambda_map = {8:15, 9:15, 10:25, 11:25, 12:15,
13:15, 14:25, 15:25, 16:15, 17:8}
records = []
for day in range(1, 61):
for hour in hours:
lam = lambda_map[hour]
count = np.random.poisson(lam)
records.append({'day': day, 'hour': hour,
'lambda': lam, 'requests': count})
df = pd.DataFrame(records)
print(df.head(10).to_string(index=False))For a Poisson random variable , both the mean and variance equal . Empirically, for each hour.
import numpy as np
import pandas as pd
np.random.seed(77)
hours = list(range(8, 18))
lambda_map = {8:15, 9:15, 10:25, 11:25, 12:15,
13:15, 14:25, 15:25, 16:15, 17:8}
records = []
for day in range(1, 61):
for hour in hours:
records.append({'hour': hour, 'lambda': lambda_map[hour],
'requests': np.random.poisson(lambda_map[hour])})
df = pd.DataFrame(records)
summary = df.groupby('hour')['requests'].agg(
mean='mean', variance='var', true_lambda='first'
)
summary['true_lambda'] = [lambda_map[h] for h in summary.index]
summary = summary.round(2)
print(summary)For each hour band the sample mean and sample variance are close to the true . The ratio (dispersion index) should hover near 1.0 for a well-fitting Poisson model.
Waiting Times: Gamma Distribution and Queue Comparison
A service centre has two queues. Standard queue waiting times follow (mean 16 min). Priority queue follows (mean 6 min). Compare distributions via empirical CDF, quantiles, and fraction served within 10 minutes.
- a)Generate 300 waiting times for each queue.
- b)Compute mean, median, Q1, Q3, and the 90th percentile.
- c)Compute the fraction served within 10 minutes for each queue.
- d)Compare the shapes: same means same coefficient of variation.
The gamma distribution with shape is unimodal and right-skewed. Scaling shifts the whole distribution.
import numpy as np
import pandas as pd
np.random.seed(11)
standard = np.random.gamma(shape=2, scale=8, size=300)
priority = np.random.gamma(shape=2, scale=3, size=300)
df = pd.DataFrame({
'wait_time': np.concatenate([standard, priority]),
'queue': ['Standard']*300 + ['Priority']*300
})
print(df.groupby('queue')['wait_time'].describe().round(2))Quantiles directly answer "what fraction of customers wait more than minutes?" — more useful than the mean alone for service-level agreements.
import numpy as np
np.random.seed(11)
standard = np.random.gamma(2, 8, 300)
priority = np.random.gamma(2, 3, 300)
for label, data in [('Standard', standard), ('Priority', priority)]:
q = np.percentile(data, [25, 50, 75, 90])
within10 = (data <= 10).mean()
print(f"{label}: Q1={q[0]:.1f}, median={q[1]:.1f}, "
f"Q3={q[2]:.1f}, P90={q[3]:.1f}, "
f"served<=10min: {within10*100:.1f}%")The empirical CDF estimates the probability of being served within minutes. The gap between the two CDFs quantifies the priority queue advantage.
Warehouse Orders: Demand and Return Rates Across Cells
Five warehouses (W1–W5) each stock five product groups (Electronics, Clothing, Food, Furniture, Books). For each warehouse–product cell, we observe total orders and total returns over one quarter. Find the cell with the highest return rate and the product group with the most variable demand.
- a)Generate a 5×5 demand matrix and a 5×5 return matrix.
- b)Compute the return rate (returns / orders) for each cell.
- c)Identify the cell with the highest return rate.
- d)Identify the product group with the highest coefficient of variation in demand across warehouses.
Demand and return counts are drawn from realistic distributions. Electronics has higher return rates; Food has the most stable demand.
import numpy as np
import pandas as pd
np.random.seed(42)
warehouses = ['W1','W2','W3','W4','W5']
products = ['Electronics','Clothing','Food','Furniture','Books']
base_demand = np.array([
[500,300,800,200,150],
[480,320,750,210,160],
[510,290,820,190,140],
[490,310,780,205,155],
[520,280,810,195,145],
], dtype=float)
noise = np.random.normal(0, 20, base_demand.shape)
demand = (base_demand + noise).clip(50).round().astype(int)
return_rate_base = np.array([0.08,0.05,0.02,0.07,0.03])
returns = np.round(demand * (return_rate_base +
np.random.normal(0, 0.01, demand.shape))).clip(0).astype(int)
df_d = pd.DataFrame(demand, index=warehouses, columns=products)
df_r = pd.DataFrame(returns, index=warehouses, columns=products)
print("Demand:"); print(df_d)
print("Returns:"); print(df_r)Return rate per cell: . A high return rate signals quality issues, sizing problems, or unmet expectations.
import numpy as np
import pandas as pd
np.random.seed(42)
warehouses = ['W1','W2','W3','W4','W5']
products = ['Electronics','Clothing','Food','Furniture','Books']
base_demand = np.array([[500,300,800,200,150],[480,320,750,210,160],
[510,290,820,190,140],[490,310,780,205,155],[520,280,810,195,145]],float)
demand = (base_demand + np.random.normal(0,20,base_demand.shape)).clip(50).round().astype(int)
return_rate_base = np.array([0.08,0.05,0.02,0.07,0.03])
returns = np.round(demand*(return_rate_base+np.random.normal(0,0.01,demand.shape))).clip(0).astype(int)
rate = returns / demand
df_rate = pd.DataFrame(rate.round(4), index=warehouses, columns=products)
print("Return rates:"); print(df_rate)
idx = np.unravel_index(rate.argmax(), rate.shape)
print(f"Highest return rate: {warehouses[idx[0]]}/{products[idx[1]]} = {rate[idx]:.3f}")The coefficient of variation measures relative variability in demand. A high CV means the product group has inconsistent demand across warehouses.
import numpy as np
np.random.seed(42)
products = ['Electronics','Clothing','Food','Furniture','Books']
base = np.array([[500,300,800,200,150],[480,320,750,210,160],
[510,290,820,190,140],[490,310,780,205,155],[520,280,810,195,145]],float)
demand = (base + np.random.normal(0,20,base.shape)).clip(50).round().astype(int)
for j, prod in enumerate(products):
col = demand[:, j]
cv = col.std(ddof=1) / col.mean()
print(f"{prod}: mean={col.mean():.1f}, std={col.std(ddof=1):.1f}, CV={cv:.4f}")Correlation Traps: Why r ≈ 0 Does Not Mean No Relationship
A dataset contains and (a perfect quadratic relationship). Compute Pearson . Observe that despite a clear non-linear relationship. Add three extreme outliers that can flip the sign of .
- a)Generate uniformly on and set .
- b)Compute Pearson and note it is near zero.
- c)Add three outliers at and recompute .
- d)Explain why Pearson only measures LINEAR association.
The relationship is deterministic and strong, but symmetric around . Positive and negative values cancel each other in the cross-product sum, driving toward zero.
import numpy as np
import pandas as pd
np.random.seed(8)
x = np.random.uniform(-3, 3, 200)
y = x**2 + np.random.normal(0, 0.5, 200)
df = pd.DataFrame({'x': x.round(3), 'y': y.round(3)})
print(df.head(8).to_string(index=False))
print(f"Pearson r = {np.corrcoef(x, y)[0,1]:.4f}")
print("Note: |r| near 0 despite strong quadratic pattern!")Pearson measures the strength and direction of the LINEAR relationship. For , the positive right-half and negative left-half contributions cancel.
Adding a few high-leverage outliers can dramatically change — even reversing its sign. This shows that is sensitive to outliers.
import numpy as np
np.random.seed(8)
x = np.random.uniform(-3, 3, 200)
y = x**2 + np.random.normal(0, 0.5, 200)
x_out = np.append(x, [10, 11, 12])
y_out = np.append(y, [5, 6, 4])
r_before = np.corrcoef(x, y)[0, 1]
r_after = np.corrcoef(x_out, y_out)[0, 1]
print(f"r without outliers: {r_before:.4f}")
print(f"r with outliers: {r_after:.4f}")
print("Three outliers can shift r by more than 0.3!")Spearman rank correlation detects monotone non-linear relationships. Always inspect a scatter plot; alone is insufficient. For the quadratic dataset, but too — here even rank correlation fails because the relationship is non-monotone. Use mutual information or polynomial regression instead.
import numpy as np
from scipy.stats import spearmanr, pearsonr
np.random.seed(8)
x = np.random.uniform(-3, 3, 200)
y = x**2 + np.random.normal(0, 0.5, 200)
r_p, _ = pearsonr(x, y)
r_s, _ = spearmanr(x, y)
print(f"Pearson r = {r_p:.4f} (near 0, misses quadratic)")
print(f"Spearman r = {r_s:.4f} (also near 0: non-monotone)")
print("Conclusion: ALWAYS plot your data before trusting r.")Final Report: The Complete Data Analysis Workflow
Summarise the end-to-end data analysis workflow practiced in this section: load → clean → describe → visualise → model → interpret. For each stage, list the key tools and common pitfalls encountered across Problems 1–10.
Read data from CSV, database, or simulation. Check dtypes, encoding, and missing values immediately after loading. Use `df.info()` and `df.head()` as sanity checks.
import numpy as np
import pandas as pd
# Simulate loading a mixed dataset (as in Problems 3-10)
np.random.seed(0)
df = pd.DataFrame({
'id': range(1, 101),
'value': np.random.normal(50, 10, 100),
'group': np.random.choice(['A','B','C'], 100),
'flag': np.random.randint(0, 2, 100)
})
print(df.info())
print(df.head(5).to_string(index=False))
print(f"Missing values: {df.isnull().sum().sum()}")Handle missing values, fix dtypes, remove or flag duplicates, and apply the outlier detection rule from Problem 4.
import numpy as np
import pandas as pd
np.random.seed(0)
values = np.random.normal(50, 10, 100)
values[::25] = np.nan # inject missing
values[5] = 999 # inject outlier
s = pd.Series(values).dropna()
q1, q3 = s.quantile(0.25), s.quantile(0.75)
iqr = q3 - q1
clean = s[(s >= q1 - 1.5*iqr) & (s <= q3 + 1.5*iqr)]
print(f"Original: n={len(values)}, missing={np.isnan(values).sum()}")
print(f"After cleaning: n={len(clean)}, "
f"mean={clean.mean():.2f}, std={clean.std():.2f}")Compute the standard descriptive statistics. Use all five: mean, median, std, IQR, and range. Do not rely on a single number.
A histogram reveals shape; a box-plot shows outliers and IQR; a scatter plot reveals relationships. As Problem 10 showed: always plot before computing . As Problems 3 and 8 showed: overlay distributions when comparing groups.
Fit the appropriate model: Poisson for counts (Problem 7), lognormal for skewed positives (Problem 4), gamma for waiting times (Problem 8), normal for exam scores (Problem 3). Validate with a goodness-of-fit test (Problem 1 used the chi-square intuition).
State conclusions in plain language with uncertainty bounds. Highlight key caveats: correlation causation; no relationship (Problem 10); higher mean better outcome when variance differs (Problem 3); mean typical value for skewed distributions (Problem 4).
# Summary template: print a mini-report for any numeric column
import numpy as np
import pandas as pd
np.random.seed(1)
data = np.concatenate([
np.random.lognormal(3.5, 0.4, 195),
np.random.uniform(300, 500, 5)
])
s = pd.Series(data, name='metric')
q1, q3 = s.quantile(0.25), s.quantile(0.75)
print("=== Mini Analysis Report ===")
print(f"n : {len(s)}")
print(f"Mean : {s.mean():.2f}")
print(f"Median : {s.median():.2f}")
print(f"Std : {s.std():.2f}")
print(f"IQR : {q3-q1:.2f}")
print(f"Skewness : {s.skew():.3f}")
print("Caveats: distribution is right-skewed; "
"median is a better centre measure than mean.")A rigorous analysis always follows all six stages. Skipping any stage risks misleading conclusions. The ten problems in this section each illustrated a different failure mode: convergence illusions (P1–P2), group heterogeneity (P3), outlier sensitivity (P4), conditional vs marginal rates (P5), bias-variance in manufacturing (P6), distributional model validation (P7–P8), multi-dimensional aggregation (P9), and the limits of Pearson correlation (P10).