The Analyst's Path

Phase 12Finance Plus, AI and the quant-code trackFinance trackDA1.0512,412 words1 h 9 min of readingabout 18 hours of studyfree

Statistics in Code

Take NILAM's 499 daily returns from nse-eod as a stand-in population (mean ≈ −0.053%, population SD ≈ 1.755%; this is a fictional synthetic ticker, not a real quote).

Advertisement

Branch: Data Science & ML (quant-ds) · Quant-Code region · Node DA1.05 · ~18 focused hours · Gate: quiz ≥ 85% + code-pack ≥ 85% (hidden tests green on at least 85% of exercises)

QM1.01 taught you to trust the machinery of estimation and QM1.02 taught you to trust the machinery of a decision (H0 and Ha, a test statistic, a p-value, a regression's coefficients and their diagnostics) all of it by hand, by table, by formula, on purpose. Both modules end the same way, with the same sentence, said twice: this is DA1.05's job now. QM1.01 hands off "sampling, hypothesis tests, and regression in scipy/statsmodels," and QM1.02 hands off the identical material "with train/test discipline," and both are explicit that neither module will show you a line of code, and this one will never re-derive a formula either of them already gave you. This module is where that promise is kept. Every test statistic you computed by hand (t, chi-square, F) becomes one function call. Every regression printout you built cell by cell in Excel and then re-derived from Sxy and Sxx becomes statsmodels.api.OLS(y, sm.add_constant(X)).fit(). The formulas do not change. What changes is the skill: knowing which call to make, reading what comes back without being fooled by how official it looks, and catching the specific ways a real analysis pipeline goes quietly wrong that a hand calculation on eight numbers never had the chance to.

Advertisement

QM1.02 opened with a warning worth repeating here in a sharper form, because code makes it worse, not better: "a chat assistant can produce a complete regression table (coefficients, standard errors, stars, an R² dressed up to look definitive) in under five seconds, and every number in it can be quietly wrong in a way that reads exactly like every number that is quietly right." A .summary() call from statsmodels has the same property. It renders as a clean, aligned, monospace table whether you fit it on the right data or the wrong data, with an intercept or without one, with the correct independence assumption or a silently violated one. Nothing about the formatting tells you which. This module's actual subject, underneath the function names, is building the reflex that treats a beautiful printout as a claim to verify, never as a verdict to accept, exactly the discipline QM1.02 built by hand, now aimed at a keyboard.

Every example below runs against this program's own bundled datasets, nse-eod/us-eod (fifteen fictional tickers a side, 500 trading days), india-fundamentals/sec-fundamentals (the same fifteen companies' financial statements), nifty-index/us-index, fred-macro/rbi-macro, and synthetic-factor-panel/synthetic-portfolio, all seeded synthetic look-alikes (R3; see app/public/datasets/MANIFEST.json for exact provenance), never real market data, so every number quoted here is exactly reproducible from the file, not a real-world fact. This module's code pack (id DA1.05, exercises DA1.05-e01 through DA1.05-e09) turns every one of the nine techniques below into a real, hidden-test-graded exercise; the module references it throughout, and §11 maps every exercise to the section that teaches it.


Learning objectives

By the end you can:

  1. Simulate a sampling distribution in code (seeded bootstrap resampling with NumPy) and use it to confirm, numerically rather than algebraically, the standard-error law QM1.01 derives: SD(x̄) = σ/√n.
  2. Run a one-sample t-test (scipy.stats.ttest_1samp), choose correctly between an independent two-sample test (ttest_ind, Welch's equal_var=False as the safer default) and a paired test (ttest_rel), and name the specific way a real dataset can silently violate the independence a ttest_ind call never checks for you.
  3. Compute a two-sample F-test for equal variances entirely from scipy.stats.f, knowing that scipy carries no single built-in function for it, and run a chi-square test of independence via scipy.stats.chi2_contingency.
  4. Test a correlation coefficient for significance with scipy.stats.pearsonr in one call, and separate a tiny-but-significant correlation (large n) from an economically meaningful one.
  5. Fit and read a simple and a multiple OLS regression (statsmodels.api.OLS(y, sm.add_constant(X)).fit()), mapping every field in .summary() (coefficient, SE, t, p, R², adjusted R², overall F) back to the exact QM1.02 formula that produces it.
  6. Run and correctly interpret all four regression diagnostics in code: het_breuschpagan, variance_inflation_factor, linear_reset (all statsmodels.stats), and Durbin–Watson, and name the one condition (a single, correctly time-ordered series) that makes the last one meaningful at all.
  7. Fit a logistic regression (statsmodels.api.Logit), read a coefficient as a log-odds effect, convert it to an odds ratio, report McFadden's pseudo-R², and build a confusion matrix at a chosen probability threshold.
  8. Build a small, linear pipeline, load real data with pandas, pick the statistically correct test or model for its actual structure, run it, and state the conclusion in one sentence that separates statistical significance from economic size, the automated form of QM1.02's "read a full regression printout in one pass."
  9. (Productivity objective: R10 duality.) Use an AI copilot under the Copilot Discipline to draft statistical code, and verify it against the specific failure modes this module names: a hallucinated function name, a wrong default (equal_var=True when the data demands Welch's), a missing intercept, or a diagnostic silently never run.

The duality, stated once (R10). Objectives 1–8 are the understanding objective: the gate (quiz ≥ 85%, code-pack ≥ 85% with hidden tests green) rewards choosing the statistically correct call for a given dataset and reading what comes back correctly, closed-book where the reading is concerned. Objective 9 is the productivity payoff: a copilot can write a scipy.stats.ttest_ind(a, b) call in one second, it cannot tell you, on its own authority, whether your a and b are actually independent, and it will not notice a missing intercept unless you know to look. Understanding is what makes the acceleration safe.


Prerequisites & connections

Builds on. DA1.01 (NumPy & Vectorized Computation) is where arrays, vectorized arithmetic, and a seeded random generator (numpy.random.default_rng) first become fluent tools, every simulation in §3 leans on exactly that. DA1.02/DA1.03 (pandas I/II) are where pd.read_csv, filtering, groupby, and time-indexed slicing become second nature, every dataset load in this module assumes that fluency and does not re-teach it. DA1.04 (EDA & Visualization) is where you learned to look at a distribution before testing it, a histogram or a residual plot before a p-value, the same "look before you test" habit Worked Example 6 in QM1.02 modeled by hand. Above all, this module builds on QM1.01 (Probability, Distributions, Sampling & Estimation) and QM1.02 (Hypothesis Testing, Correlation & Regression), the formulas, the assumptions, the failure modes, all taught there, by hand, first.

BINDING BOUNDARY (C-3). QM1.01 and QM1.02 teach statistics and regression as concepts, the intuition, the exact math, the failure modes, computed by hand and by table lookup. This module teaches the identical material in software: scipy.stats, statsmodels.OLS/Logit, real datasets, hidden-test grading. The boundary runs in both directions and neither side crosses it: this module never re-derives a formula QM1.01 or QM1.02 already gave you, every test statistic's formula, every assumption, every diagnostic's definition is cited back, not rebuilt from scratch, and in exchange this module owns everything QM1.02 explicitly declined to teach: which function to call, what its defaults actually do, how a real dataset's shape can silently break an assumption the function itself never checks, and how to read what comes back without being fooled by clean formatting. If you find yourself re-deriving Sxy/Sxx or the Breusch–Pagan LM statistic's algebra here, stop, that content already exists, in QM1.02, and citing it is the correct move, not repeating it.

Feeds forward. DA2.01 (ML I. Supervised Learning) picks up exactly where this module's logistic regression leaves off, turning "fit one model and read its coefficients" into a full predictive-modeling discipline: train/validation/test splits, cross-validation, multiple model families, and metric choice for a genuinely held-out evaluation, this module fits one model on all the data it has and reads it as a statistical object; DA2.01 is where "will this generalize" becomes the actual question. DA2.02 (ML II. Unsupervised, Feature Engineering & Model Discipline) leans on this module's diagnostic instincts (a clean-looking fit can still be broken) when it teaches look-ahead and target leakage. QD1.02 (Signal & Factor Construction and Evaluation) is where this module's factor-panel regression returns properly: pooling a panel across time the way §8–9 do here is a simplification flagged explicitly below, and QD1.02 is where Fama–MacBeth and panel-aware standard errors replace it for real. QD2.02 (Risk Systems in Code) and QD2.03 (Performance & Attribution Analytics in Code) both lean on this module's hypothesis-testing fluency. VaR backtesting and the deflated Sharpe ratio are, underneath, exactly the test-statistic-and-p-value machinery built here, aimed at a new question.

Cross-branch companions (soft, not gates). None required. This module's datasets recur across the quant-code region: NILAM and BRPEAK, the two tickers most used below, are the same fictional companies CS2.03 queried by SQL; if you have already met them there, the continuity is deliberate, not a coincidence.


Core teaching content

1. Why "run the test in code" is a different skill than "know the formula"

QM1.02 taught you exactly what a t-statistic, a chi-square statistic, and an F-statistic are testing, and how each is built from a sample. None of that changes here. What changes is where the risk moves. When you compute a paired t-test by hand from twelve numbers, the "am I even allowed to do this?" question is answered by the fact that you had to sit and look at the data to compute anything at all, you cannot accidentally paired-test two series of different lengths, because the subtraction step fails loudly. scipy.stats.ttest_ind(a, b) has no such friction. It will happily accept two return series that happen to share the same 500 calendar days, treat them as wholly unrelated, and hand back a p-value with the same clean formatting whether that choice was right or catastrophically wrong. The function does not know what your data is; it only knows the numbers you gave it. Every one of this module's nine techniques has this same shape: the code is nearly always shorter and easier to write than the assumption-checking it silently skips.

This is why the whole module runs against real, messy-shaped datasets instead of clean, hand-picked arrays: a well-chosen textbook example rarely tempts you into the mistake a genuine dataset does, pooling four years of the same fifteen companies' data and forgetting they are not sixty independent draws, or feeding a panel's residuals into a serial-correlation test in an order that isn't actually chronological. §4 and §9 walk through exactly these two traps because they are the two most common ways this module's own techniques, not QM1.02's formulas, go wrong in practice.

2. The toolbox, and the runtime you are actually running in

Two libraries do essentially everything below. scipy.stats is the hypothesis-testing workhorse: every named test (ttest_1samp, ttest_ind, ttest_rel, chi2_contingency, pearsonr) plus every distribution object (stats.f, stats.chi2, stats.norm) you already met in QM1.01's own one-line equivalents. statsmodels (imported as statsmodels.api as sm, with statsmodels.stats.diagnostic and statsmodels.stats.outliers_influence for the diagnostics) is the regression workhorse, both OLS and logistic regression, plus every diagnostic test QM1.02 named by function: het_breuschpagan, variance_inflation_factor, linear_reset. pandas loads and shapes the data feeding both.

All three (plus NumPy, scikit-learn, and Matplotlib) are genuinely available in this program's live, in-browser Python runtime (Pyodide): every exercise in this module's code pack runs for real, in your browser, with no server round-trip. Two things are not available and never will be inside a graded exercise here: live network access (no fetching a fresh price quote mid-exercise, every dataset is a bundled, versioned file) and a GPU-backed deep-learning framework (PyTorch/TensorFlow are DA2.03's territory, and only there as a conceptual pointer). Nothing in this module needs either. Datasets are always referenced by a bundled id (nse-eod, us-eod, synthetic-factor-panel, and so on) loaded with pandas.read_csv('/datasets/<id>.csv') exactly as shown throughout this module, never pasted inline; the full manifest, with each dataset's row count and provenance, lives at app/public/datasets/MANIFEST.json.

3. Simulating a sampling distribution: confirming the CLT instead of taking it on faith

QM1.01 states the sampling distribution of the mean as a formula: for a population with mean μ and variance σ², the mean of samples of size n has SD(x̄) = σ/√n (the standard error) and the Central Limit Theorem says this holds approximately, for the sample mean specifically, however skewed the population itself is, once n is "large enough." Code lets you stop trusting that claim and check it, by literally repeating the sampling experiment thousands of times and looking at what happens.

Take NILAM's 499 daily returns from nse-eod as a stand-in population (mean ≈ −0.053%, population SD ≈ 1.755%; this is a fictional synthetic ticker, not a real quote). Draw a bootstrap sample of size n with replacement, take its mean, and repeat that trials times with a seeded generator:

import numpy as np

def bootstrap_standard_error(sample, n, trials, seed):
    rng = np.random.default_rng(seed)
    sample = np.asarray(sample, dtype=float)
    means = np.array([rng.choice(sample, size=n, replace=True).mean() for _ in range(trials)])
    return float(means.std(ddof=1))

At n = 10, 30, and 100 (seed = 7, 5,000 trials each), the empirical standard deviation of those simulated sample means comes out to 0.005615, 0.003216, and 0.001742: against QM1.01's theoretical σ/√n of 0.005549, 0.003204, and 0.001755 at those same three n. The formula and the simulation agree to three decimal places without either one ever looking at the other. Quadrupling n from 25 to 100 on a second, hand-built 30-value synthetic sample gives an empirical SE ratio of 2.023. QM1.01's own "quadruple n, halve the standard error" rule, reproduced by brute-force resampling rather than algebra. This is exactly DA1.05-e01's shape.

The same resampling idea gives you a second, formula-free route to a confidence interval: instead of QM1.01's x̄ ± t*·(s/√n), take the 2.5th and 97.5th percentile of the bootstrap means directly. On NILAM's own return series (5,000 resamples, seed 99), the bootstrap percentile interval comes out to (−0.209%, 0.107%) against the formula-based t-interval's (−0.208%, 0.101%), two different methods, agreeing to within a hundredth of a percentage point, which is itself the whole point of running both: when they disagree by a lot, on real data, that disagreement is a diagnostic in its own right (usually a sign the population is skewed enough that "large enough n" for the CLT has not actually been reached yet).

4. One-sample and independent two-sample tests: and a trap the formula never warns you about

A one-sample test in code is the shortest function in this module: scipy.stats.ttest_1samp(sample, mu0) returns (t_statistic, p_value) directly, testing H0: population mean = mu0. Run it against KOKILA's 499 daily returns (India, nse-eod) versus H0: mean = 0, and you get t = 1.861, p = 0.0633, not quite significant at 5%, a close call exactly in the spirit of QM1.02's Common Mistake #2 ("failing to reject does not prove H0"; a slightly longer sample, or a slightly larger true effect, would likely flip this). Run the identical function against NRTHBY (US, us-eod) and you get t = 2.847, p = 0.0046, genuinely, comfortably significant: this fictional ticker's simulated mean daily return really is distinguishable from zero over this window. Same function, same formula, two real datasets, two different honest verdicts: DA1.05-e02 grades exactly this pair.

Independent two-sample comparisons are scipy.stats.ttest_ind(group_a, group_b, equal_var=False). Welch's test, the safer default QM1.02 names explicitly, because it does not assume the two groups share a variance. Here is the trap the function itself will never warn you about. Take each market's fifteen companies' FY2025 net margin from india-fundamentals/sec-fundamentals, one row per company, genuinely fifteen independent firms a side, and Welch's test gives t = 0.507, p = 0.616: no detectable difference between India's and the US's typical net margin in this synthetic panel. Now pool all four fiscal years per company instead (sixty rows a side, because each of the fifteen companies contributes four years), without changing a single line of the test call, and you get t ≈ 0.00008, p ≈ 0.9999, an almost perfectly, suspiciously flat null result. ttest_ind ran without complaint both times, and the second call is wrong: those sixty "independent" observations are really fifteen companies measured four times each (pseudo-replication) and treating repeated measurements on the same firm as fresh independent draws manufactures an artificially large effective sample size and an artificially small standard error. The fix is not a different function; it is choosing the right rows before the function ever runs, one observation per independent unit (here, the latest fiscal year only), or, beyond this module's scope, a model that explicitly accounts for the repeated-measures structure. The independent-vs-paired choice QM1.02 drills is about picking the right formula for two data series you already have; this is the code-native cousin of that same discipline, picking the right rows before any formula runs at all. DA1.05-e03 grades the correctly-constructed, one-row-per-company version.

5. Paired tests, correctly chosen

QM1.02's own Worked Example 1 shows what happens when you skip this choice: a fund and its benchmark, measured on the same 63 trading days, are correlated by construction (ρ = 0.99 in that example), and treating them as independent inflates the standard error and nearly buries a real effect. scipy.stats.ttest_rel(series_a, series_b) is the paired test, it takes the day-by-day differences and runs a one-sample test on those, exactly QM1.02's hand formula, in one call.

Build a fifteen-holding synthetic portfolio's daily return (synthetic-portfolio, fixed target weights) and compare it, day for day, against the US broad index (us-index) over the same 499 trading days: t = −1.191, p = 0.234, no detectable difference in mean daily return between this portfolio and its benchmark. Pair the same portfolio against the India broad index (nifty-index) instead, same days: t = 1.218, p = 0.224, a different comparison, a different sign, the same honest "not detectable" verdict. Both are legitimate paired comparisons (same calendar days, genuinely correlated series) and both happen to land on "fail to reject" here, which is exactly why running the correct test matters even when the answer turns out to be a shrug: a paired test done right, that finds nothing, is a completely different, more trustworthy result than an independent test done wrong that also finds nothing. DA1.05-e04 grades both directions.

6. Two tests scipy doesn't hand you as one clean call

QM1.02's F-test for equal variances puts the larger sample variance on top (so F ≥ 1 by construction) and compares it to an F critical value. Look for a matching scipy.stats.ftest_ind and you will not find one, scipy has no single built-in two-sample F-test function. (Do not reach for scipy.stats.f_oneway as a substitute: that is one-way ANOVA, testing whether several groups' means differ, an entirely different question from whether two groups' variances differ.) You build the tail probability yourself, directly from the distribution object, exactly the way QM1.01's own Python aside used stats.chi2.ppf for a critical value:

from scipy import stats

def variance_ratio_test(sample_a, sample_b):
    var_a, var_b = stats.tvar(sample_a), stats.tvar(sample_b)   # ddof=1 by default
    df_a, df_b = len(sample_a) - 1, len(sample_b) - 1
    if var_a >= var_b:
        f_stat, df_num, df_den = var_a / var_b, df_a, df_b
    else:
        f_stat, df_num, df_den = var_b / var_a, df_b, df_a
    p_value = 2 * (1 - stats.f.cdf(f_stat, df_num, df_den))
    return float(f_stat), float(p_value)

Run this on CHANDANI versus KOKILA (both India, nse-eod, 499 daily returns each): F = 1.2945, p = 0.00404, reject equal variances; KOKILA is genuinely the more volatile name. Run the identical function on IRNVLE versus BLUSTM (both US, us-eod): F = 1.2902, p = 0.00454, the same verdict, independently, in the other market. DA1.05-e05 grades this exact function; the lesson is not the F-test's arithmetic (QM1.02 already gave you that) but the fact that a library gap is itself something you must recognize, not paper over with the nearest similarly-named function.

Chi-square independence is the more forgiving case: scipy.stats.chi2_contingency(table, correction=False) (the correction=False matches QM1.02's own uncorrected formula exactly) hands back (chi2, p_value, dof, expected) for any contingency table in one call. Cross-tabulate every daily up/down move across all fifteen India tickers against all fifteen US tickers over the full 500-day window (7,485 daily observations a side: India 3,826 up/3,659 down; US 3,724 up/3,761 down) and you get chi2 = 2.780, p = 0.0954, not significant: the probability a given day's move is "up" does not detectably depend on which market you are in, in this synthetic universe. DA1.05-e06 grades exactly this call, and a second, structurally different 2×3 table to confirm the function generalizes past a bare 2×2.

7. Correlation significance: and the large-n trap in the other direction

scipy.stats.pearsonr(x, y) returns (r, p_value) directly. QM1.02's t = r√(n−2)/√(1−r²) machinery, run for you. The trap here runs the opposite way from QM1.02's Worked Example 4 (a supplier and its customer, r = 0.42, n = 24, significant and a meaningful ~18% of shared variance). Take synthetic-factor-panel's value_factor against its fwd_return, pooled across sixty tickers and forty-eight months (n = 2,880): r = 0.0988, p = 1.09 × 10⁻⁷. The correlation is overwhelmingly, unmistakably statistically significant, and r² ≈ 0.0098, meaning this factor explains under one percent of the variance in next month's return. With n in the thousands, even a genuinely tiny effect becomes statistically undeniable; that is a fact about sample size, not about economic importance, and it is the sharpest possible illustration of QM1.02's Common Mistake #8 ("statistically significant means it matters for the portfolio" is false, significance is a floor, never a ceiling). The same panel's low_vol_factor against fwd_return gives r = −0.0087, p = 0.642, genuinely indistinguishable from zero, the honest opposite case. DA1.05-e07 grades both.

8. OLS regression in code: fitting and reading the printout

The building block is one line: statsmodels.api.OLS(y, sm.add_constant(X)).fit(). Note sm.add_constant explicitly, unlike a spreadsheet's LINEST, statsmodels does not add an intercept for you; forgetting it silently forces the fitted line through the origin and biases every coefficient if the true intercept is not actually zero. This single missing line is one of this module's most common real bugs precisely because the regression still runs, still prints a clean-looking table, and gives you no error to catch it.

Fit fwd_return on momentum_factor alone, pooled across the full synthetic-factor-panel (n = 2,880): the coefficient on momentum is 0.006307 (t = 6.608, p < 0.0001), R² = 0.0149. Exactly as QM1.02 states for a simple regression, F equals t² here: 6.608² = 43.67, matching the model's reported F-statistic to the fourth decimal, a costless cross-check worth running every time. Add three more predictors (value_factor, quality_factor, low_vol_factor) and the multiple regression reads:

TermCoefficientp-value
Intercept−0.00006460.945
value_factor0.004904< 0.0001
momentum_factor0.006216< 0.0001
quality_factor0.004198< 0.0001
low_vol_factor−0.0006630.476

R² = 0.0315, adjusted R² = 0.0301, overall F = 23.35 (p = 5.04 × 10⁻¹⁹). Recomputing adjusted R² directly from QM1.02's own formula: 1 − (1 − R²)(n − 1)/(n − k − 1) with n = 2,880 and k = 4, gives 1 − (1 − 0.031470)(2879/2875) = 0.030122, matching statsmodels' own reported figure to six decimal places: the printed "Adj. R-squared" is not a mystery number, it is exactly that formula, applied. Three of the four factors are individually significant; low_vol_factor is not, and, per §9 below, that pattern is not a multicollinearity artifact here (unlike QM1.02's own credit-spread capstone, where two predictors' overlap muddied the reading). DA1.05-e08 grades this function on both the four-predictor and the single-predictor case, so a solution hardcoded to one specific column count fails immediately.

9. The four diagnostics: in code, and one honest gap

Every diagnostic QM1.02 names by formula has a named statsmodels function. Heteroskedasticity: statsmodels.stats.diagnostic.het_breuschpagan(resid, exog) returns the LM statistic and its p-value directly, on the four-factor model above, p = 0.0134: mild but real heteroskedasticity, meaning the coefficients stay trustworthy but the printed standard errors and p-values are somewhat optimistic. Multicollinearity: statsmodels.stats.outliers_influence.variance_inflation_factor(Xc, i) computes QM1.02's VIF_j = 1/(1 − R²_j) directly from the auxiliary regression; every one of the four factors here comes back at essentially 1.00, a clean pass, the opposite of QM1.02's own high-VIF capstone, and the reason all four coefficients above are individually readable without a multicollinearity asterisk. Misspecification: statsmodels.stats.diagnostic.linear_reset(model, power=2, use_f=True) runs Ramsey's RESET automatically; here it flags p = 0.00355 on the four-predictor model (some missing curvature or interaction) but p = 0.628 on the single-predictor momentum-only model, adding predictors changed not just the fit but which diagnostics fire, a real, honest, common pattern.

Serial correlation is the one diagnostic that does not reduce to "call the function and read the number." statsmodels.stats.stattools.durbin_watson(resid) computes DW = Σ(eₜ − eₜ₋₁)²/Σeₜ² from whatever order the residuals happen to be in, and that formula is a function of which rows sit adjacent to each other, full stop. On a genuine single time series (one ticker, its own months in chronological order) that question is meaningful. On synthetic-factor-panel's file order, sixty tickers interleaved within each date before the date advances, adjacent rows are almost always two different tickers on the same date, not one ticker's own consecutive months; a DW computed on that row order is answering a question you never actually asked. Sort by ticker-then-date first, each ticker's own forty-eight-month run sitting contiguously, and then DW is asking about genuine serial correlation. On this particular dataset both orderings happen to land near 2.0 (the residuals here carry essentially no serial correlation to detect either way, so there is no dramatic reversal to show), but do not take that as license to skip the sort: DW is mechanically incapable of distinguishing "no serial correlation exists" from "I never actually checked serial correlation, because my rows weren't in time order," and only one of those two is a fact about your data.

10. Logistic regression: new machinery, read correctly from the start

Everything above predicts a number. Logistic regression predicts the log-odds of a 0/1 outcome, which QM1.02 never covers, a genuinely new idea, so read it precisely rather than by analogy to OLS. statsmodels.api.Logit(y, sm.add_constant(X)).fit(disp=0) fits it; a coefficient b means "a one-unit increase in this predictor changes the log-odds of the outcome by b," and exp(b), the odds ratio, is the multiplicative factor it applies to the odds (not the probability) of the outcome.

Classify each row of synthetic-factor-panel as an "up" month (fwd_return > 0; base rate 49.20% across all 2,880 rows) and fit it on the same four factors used in §8:

TermCoefficient (log-odds)Odds ratiop-value
value_factor0.20411.226< 0.0001
momentum_factor0.18761.206< 0.0001
quality_factor0.14361.154< 0.0001
low_vol_factor−0.02420.9760.519

Read value_factor's row correctly, in full: a one-unit increase in the value z-score multiplies the odds of an up-month by about 1.226, holding the other three factors fixed, not "a 20% higher probability of an up-month," a common and wrong shortcut from reading a log-odds coefficient as if it were a probability. McFadden's pseudo-R² here is 0.0173, deliberately, honestly tiny, the classification analogue of §7's r² ≈ 0.0098: real return-sign prediction from a handful of style factors explains very little variance either way, in probability terms or in log-odds terms. At a 0.5 probability threshold, the model classifies 55.69% of rows correctly against the 49.20% base rate of always guessing "up", a genuine, measurable lift, and also a small one; you would still be wrong nearly half the time. Both facts belong in the same sentence, exactly as QM1.02's Common Mistake #8 insists for a plain regression: statistically real and economically modest are not a contradiction, in a classifier any more than in an OLS fit. DA1.05-e09 grades this fit on the full four-factor panel and on a single-predictor slice.

11. Where this leaves you: and the code pack

You can now simulate a sampling distribution and confirm the CLT numerically; run a one-sample, independent, and paired t-test correctly, and name the specific way pooling panel data can silently fake independence; build the two-variance F-test scipy does not hand you; run a chi-square test of independence and a correlation significance test; fit and fully read an OLS regression, mapping every printed number to its QM1.02 formula; run all four regression diagnostics and know exactly which one (Durbin–Watson) demands a correctly time-ordered series to mean anything at all; and fit a logistic regression, reading its coefficients as log-odds and its lift over the base rate honestly.

{
  "skill": "Reviewing AI-drafted statistical code for the specific failure modes that render cleanly and mean nothing",
  "use": "Once your own function passes the visible example, have an AI assistant review it specifically for this module's named traps -- a ttest_ind call with no explicit equal_var choice, a regression fit with no sm.add_constant, a Durbin-Watson call on data that was never actually sorted into time order, pseudo-replicated rows treated as independent -- as a structured second pair of eyes, never as the author.",
  "tools": ["AI coding assistants -- Claude, ChatGPT, GitHub Copilot", "This module's code pack's hidden tests, as the actual arbiter"],
  "prompt": "Here is my statistics/regression code and a one-line description of where the data came from: <YOUR CODE + DATA DESCRIPTION>. Specifically check: (1) if this compares two groups, are they genuinely independent, or could the same underlying unit appear more than once across the two groups? (2) if this fits a t-test, is equal_var set deliberately, not left at its default? (3) if this fits a regression, is an intercept actually added? (4) if this computes Durbin-Watson, are the rows verified to be one single series in true chronological order? Do not just say it looks fine -- answer each of the four explicitly.",
  "verify": "Run the reviewed code against this module's code pack's hidden tests on a DIFFERENT dataset slice than the one you eyeballed -- an AI review can miss exactly the class of bug it was asked to check for, especially the pseudo-replication and DW-ordering traps, since both produce a clean-looking, plausible number rather than an error (Guardrail-style: verify, then trust).",
  "diy": "You must already be able to say, yourself, why treating repeated per-company measurements as independent rows is wrong, and why Durbin-Watson depends on row order, before a review checklist like this means anything to you -- otherwise you cannot tell a correct 'looks fine' from an incorrect one.",
  "market": "IN"
}

Every one of those ideas is a real, individually graded exercise in this module's code pack (pack id DA1.05, 9 exercises, gradingMode: asserts, run live against the bundled datasets):

ExerciseConceptSection
DA1.05-e01Bootstrap standard error, confirming the CLT by simulation§3
DA1.05-e02One-sample t-test§4
DA1.05-e03Independent (Welch) two-sample t-test, the pseudo-replication trap§4
DA1.05-e04Paired t-test§5
DA1.05-e05Two-variance F-test (no built-in scipy function)§6
DA1.05-e06Chi-square test of independence§6
DA1.05-e07Correlation significance§7
DA1.05-e08OLS regression, fully diagnosed (BP, VIF, RESET)§8–9
DA1.05-e09Logistic regression, log-odds, odds ratios, confusion matrix§10

Open the pack, write each function exactly as you reasoned it out above, and watch real scipy/statsmodels (not a trace on paper) confirm you got it right. Every market-data exercise ships hidden tests built from both an India-flavored and a US-flavored slice of data, so a solution that only works on the one example you happened to test by hand is caught immediately, the same anti-hardcoding discipline every code pack in this program enforces.


Common mistakes & how experts think differently

  1. "ttest_ind ran without an error, so my groups must be valid independent samples." The function has no way to know whether your two arrays are genuinely separate units or the same units measured more than once, it only ever sees numbers. §4's pseudo-replication trap (fifteen companies' four fiscal years pooled into "sixty independent" rows) shows the function returning a confident, wrong-shaped null result without a single warning. The expert asks "could the same real-world unit appear more than once across my two groups?" before the function ever runs, because nothing downstream will ask it for you.
  2. Leaving equal_var at its default. scipy.stats.ttest_ind(a, b) defaults to equal_var=True, the less safe Student's-t assumption QM1.02 calls out as needing to be verified, not assumed. Silently accepting the default is silently choosing the riskier test. The expert reflex: write equal_var=False explicitly, every time, unless equal variances have actually been checked (§6's F-test or a Levene's test) and confirmed.
  3. Believing "scipy has no function for this" means "this can't be checked." There is no built-in two-sample F-test in scipy, and reaching for the similarly-named f_oneway (one-way ANOVA on means, not a variance-equality test at all) is a real, plausible wrong turn. The expert's habit when a library gap shows up: build the missing piece from the distribution object directly (§6), the same way QM1.01 built critical values from stats.chi2.ppf, a library gap is a prompt to apply the formula, not a signal to skip the check.
  4. Forgetting sm.add_constant. Unlike a spreadsheet's LINEST or SLOPE/INTERCEPT pair, statsmodels.api.OLS adds no intercept unless you add one yourself, the regression still fits, still prints a clean table, and silently forces the line through the origin if you forget. The expert checks for this line before trusting a single coefficient, every time, because the failure mode produces no error of any kind.
  5. Running Durbin–Watson on data that was never actually sorted into one time-ordered series. DW is a function of which rows sit adjacent to each other; on a panel interleaved by date-then-ticker, adjacent rows are usually two different tickers on the same date, not one ticker's own consecutive periods, a technically-computed, practically meaningless number. The expert sorts by entity-then-time first and only then trusts what DW reports; a DW near 2.0 on unsorted panel data proves nothing about serial correlation.
  6. "The AI wrote the regression and the printout looks clean, so it's fine." A hallucinated function name at least throws an error you will notice. A missing equal_var=False, a missing intercept, or an untested independence assumption produces a clean, plausible, wrong number, the single most dangerous class of AI-assisted statistical bug, because nothing about the output flags it. The expert treats a diagnostic that was not explicitly requested as one that was never run, exactly as QM1.02 insists for a hand-built regression, now for a machine-built one too.
  7. Reading a logistic regression's coefficient as if it were a probability. A coefficient of 0.20 is a change in log-odds, not "a 20-percentage-point change in probability", a natural but wrong shortcut, since the sigmoid that connects log-odds to probability is nonlinear and depends on where you start. The expert converts to an odds ratio (exp(coefficient)) before saying anything about "how much," and still narrates it as an odds multiplier, not a probability shift.
  8. Treating a tiny p-value as proof of a large effect. With n in the thousands, a correlation as small as r = 0.099 (r² under 1%) still produces p < 10⁻⁶, the p-value got small because the sample is huge, not because the relationship is economically important. The expert reports r² (or the regression's own R²) alongside any p-value from a large sample, because statistical significance and economic significance answer two different questions and a large n can make the first one true almost for free.
  9. Judging a classifier by "better than a coin flip" alone. This module's logistic fit beats its 49.2% base rate with 55.7% accuracy, real, and also modest; "beats a coin flip" is a low bar precisely because a naive base-rate guess is a low bar. The expert compares any classifier's accuracy specifically to the base-rate (always-guess-the-majority-class) baseline, not to 50%, before calling a lift meaningful.

Worked examples

A note on the data: every ticker, index, fiscal figure, and factor value below comes directly from this program's bundled, seeded synthetic datasets (R3; see app/public/datasets/MANIFEST.json), none of it is a real market quote or a real company's actual filing, and every number quoted is exactly reproducible by running the shown code against the bundled files.

Worked example 1: Confirming the CLT by simulation (India, ₹, nse-eod)

An analyst wants to know whether QM1.01's standard-error formula, SD(x̄) = σ/√n, actually holds for a real (if fictional) return series, rather than taking the theorem on faith.

Solution. Step 1, the population. NILAM's 499 daily returns from nse-eod have mean ≈ −0.0531% and population SD ≈ 1.7547%. This series is mildly right-skewed (skewness ≈ 0.19), not the clean bell curve QM1.01's theorem does not actually require. Step 2: simulate. Using bootstrap_standard_error from §3 with a seeded generator, draw 2,000 resamples of size n = 30 (seed 11): the empirical standard error of those 2,000 sample means is 0.0031963. Step 3: compare to theory. QM1.01's formula predicts σ/√30 = 0.017547/√30 = 0.0032037, a difference of 0.0000074, well under a quarter of one percent relative. Step 4, read it. The simulation and the formula agree despite the population's mild skew, exactly as QM1.01 promises once n is "large enough", and because the simulation never once consulted the formula, this is a genuine independent confirmation, not a circular check. Repeating the same procedure on BRPEAK (US, us-eod, seed 13, n = 30): empirical SE 0.0032798 against theoretical 0.0033326, the same close agreement, in the other market.

Worked example 2: One-sample t-tests: a reject and a near-miss, side by side (US $, India ₹)

Two fictional tickers, two identical function calls, two different honest verdicts, the point of this example is the contrast, not either result alone.

Solution. Step 1, the US case. NRTHBY's 499 daily returns average 0.2358% with a standard deviation of 1.8499%. scipy.stats.ttest_1samp(returns, 0.0) returns t = 2.847, p = 0.0046. Step 2: decide. At any conventional α, reject H0: mean = 0, this fictional ticker's simulated mean daily return is genuinely, comfortably distinguishable from zero over this window. Step 3, the India case. KOKILA's 499 daily returns average 0.1574% with a standard deviation of 1.8890%; the identical function call returns t = 1.861, p = 0.0633. Step 4, decide, and read the boundary honestly. This fails to reject at the conventional 5% level, but only just. QM1.02's Common Mistake #2 applies directly: this is not evidence the true effect is zero, only that this particular sample did not clear the bar; a slightly longer window or a slightly larger true effect could easily flip the verdict, and the honest write-up says exactly that, not "no effect."

Worked example 3: The pseudo-replication trap: independent two-sample tests done wrong, then right (India ₹, US $)

An analyst wants to know whether India-listed and US-listed companies in this synthetic universe carry different typical net margins.

Solution. Step 1, the tempting, wrong approach. Pool all four fiscal years (2022–2025) of both india-fundamentals and sec-fundamentals, giving what looks like sixty observations a side. scipy.stats.ttest_ind(india_margins, us_margins, equal_var=False) runs without complaint and returns t ≈ 0.00008, p ≈ 0.9999, an almost perfectly flat null result. Step 2: name the error. These are not sixty independent draws; they are fifteen companies, each measured four times. The same firm's margin in 2022, 2023, 2024, and 2025 is not four fresh pieces of evidence, it is one firm's persistent characteristics, sampled four times, and treating it as four independent observations manufactures an inflated effective sample size the formula has no way to detect. Step 3, the correct comparison. Restrict to one row per company (FY2025 only, fifteen India firms against fifteen US firms) and the identical function call gives t = 0.507, p = 0.616. Step 4, read the contrast. Both calls "fail to reject," but for entirely different reasons: the correct version is an honest null result from a genuinely independent, properly-sized sample; the pooled version is a coincidentally similar-looking number produced by an invalid sample construction that happened not to change the conclusion this time. Re-run the correct, one-row-per-company version on FY2022 instead (a different year, a genuine anti-hardcoding check): t = −1.643, p = 0.112, still not significant, and still a properly independent comparison, this time leaning (not significantly) the other direction.

Worked example 4: Paired t-test: a portfolio against two benchmarks, correctly chosen (US $, India ₹)

A fifteen-holding synthetic portfolio (synthetic-portfolio, fixed target weights) needs its mean daily return compared against a benchmark, on the same trading days.

Solution. Step 1: recognize the pairing. The portfolio and any broad index share the identical 499 trading days; this is a paired, not independent, comparison by construction, exactly QM1.02's fund-vs-benchmark motif. Step 2, the US comparison. Weight-sum the portfolio's daily return, align it against the US broad index's (us-index) daily return on the same dates, and run scipy.stats.ttest_rel: t = −1.191, p = 0.234. Step 3, the India comparison. Align the identical portfolio against the India broad index (nifty-index) instead: t = 1.218, p = 0.224. Step 4, read both. Neither comparison detects a significant difference between the portfolio and its benchmark, a legitimate, honest result precisely because the test choice was correct in both cases (same instrument, same days, genuinely paired). Had this portfolio instead been compared to each index using the naive independent-samples formula, the artificially inflated standard error from ignoring the day-by-day correlation would only have made a real difference harder to detect, never easier, which is exactly why choosing paired over independent is not a formality here.

Worked example 5: The two-variance F-test scipy will not hand you (India ₹, US $)

An analyst wants to know whether two same-market tickers carry statistically different realized volatility.

Solution. Step 1, the India pair. CHANDANI's 499 daily returns have a standard deviation of 1.6603%; KOKILA's have 1.8890%. Using §6's variance_ratio_test (larger variance on top, tail probability from scipy.stats.f.cdf): F = 1.2945, df = (498, 498), p = 0.00404. Step 2: decide. Reject H0: equal variances: KOKILA is genuinely, detectably the more volatile of the two, at this sample size. Step 3, the US pair. IRNVLE (1.7113%) versus BLUSTM (1.9438%): F = 1.2902, same degrees of freedom, p = 0.00454, the same verdict, independently, in the other market. Step 4, read it. Both F-ratios are modest in absolute size (about 1.29, versus QM1.02's own two-fund example that failed to reject at F = 2.621 with only 12 observations a side), the difference here is that 499 observations a side is a much larger sample than QM1.02's twelve-month example, so even a smaller true variance gap clears statistical significance. Sample size changes what counts as "detectable," not what counts as "true."

Worked example 6: Chi-square test of independence: is direction of the daily move tied to the market? (both markets, one test)

Solution. Step 1: build the table. Across all fifteen India tickers and all 499 trading days each (7,485 daily moves), 3,826 are "up" (close > previous close) and 3,659 are "down." Across the matching US universe, 3,724 are up and 3,761 are down. Step 2: run the test. scipy.stats.chi2_contingency([[3826, 3659], [3724, 3761]], correction=False) returns chi2 = 2.780, p = 0.0954, dof = 1. Step 3: decide. Fail to reject independence at the conventional 5% level: whether a given day's move is up or down does not detectably depend on which of these two synthetic markets you are looking at. Step 4, read it, and connect back. This is the same machinery as QM1.02's Worked Example 2 (sector versus rating action, which did find a significant association), same formula, same function, a different dataset landing on the opposite honest verdict. A chi-square test does not know in advance which way its own data will land, and reporting "not significant" accurately is exactly as much the point of running the test as reporting "significant" would have been.

Worked example 7: OLS regression, fully diagnosed (a genuine cross-sectional-style factor panel)

Solution. Step 1: fit the full model. Regress fwd_return on value_factor, momentum_factor, quality_factor, and low_vol_factor, pooled across synthetic-factor-panel's sixty tickers and forty-eight months (n = 2,880), with sm.add_constant. Three of four coefficients are individually significant (value 0.004904, momentum 0.006216, quality 0.004198, all p < 0.0001); low_vol_factor is not (−0.000663, p = 0.476). R² = 0.0315, adjusted R² = 0.0301, overall F = 23.35 (p = 5.04 × 10⁻¹⁹). Step 2, read the printout in QM1.02's own five-step order. (i) Signs are all economically plausible for value/momentum/quality tilts. (ii) Three of four coefficients clear |t|; the fourth does not. (iii) The overall F is strong and agrees with three of the four individual tests, no red flag requiring a VIF check on its own. (iv) Adjusted R², at 0.0301, is barely below raw R², almost no penalty, meaning none of the four predictors is anywhere close to useless. (v) Run all four diagnostics regardless. Step 3, the diagnostics. Breusch–Pagan: p = 0.0134 (mild heteroskedasticity, standard errors are somewhat optimistic, coefficients remain unbiased). VIF: every factor comes back at essentially 1.00 (maximum 1.0013), a clean pass, meaning low_vol_factor's insignificance is a genuine absence of signal, not a multicollinearity artifact hiding a real effect, unlike QM1.02's own credit-spread capstone. RESET: p = 0.00355, some missing curvature or interaction is flagged. Durbin–Watson, computed correctly (sorted ticker-then-date, each ticker's own forty-eight months contiguous): 2.060, essentially no serial correlation, as expected for a model with no genuine time-series structure being modeled. Step 4, the verdict. A real, jointly significant, individually-mostly-significant factor model with a small but genuine R², clean of multicollinearity, mildly heteroskedastic, and flagged once for possible nonlinearity, reported in full, the way QM1.02 insists a diagnosed regression always must be, whichever way each individual check lands.

Worked example 8: Logistic regression: from a number to a direction (the same panel, a new lens)

Solution. Step 1: reframe the question. Instead of predicting the size of fwd_return, predict its sign: is next month's return positive (1) or not (0)? The base rate across all 2,880 panel rows is 49.20% up. Step 2: fit. statsmodels.api.Logit on the same four factors: value_factor's coefficient is 0.2041 (p < 0.0001), momentum 0.1876 (p < 0.0001), quality 0.1436 (p < 0.0001), low_vol −0.0242 (p = 0.519), the same three factors significant, the same one factor not, as the OLS fit in Worked Example 7, a reassuring internal consistency between the continuous and binary views of the same signal. Step 3, read the coefficients correctly. Convert to odds ratios: exp(0.2041) = 1.226 for value_factor, a one-unit increase in the value z-score multiplies the odds of an up-month by about 1.226, holding the other three factors fixed. This is not "a 22.6% higher probability"; the log-odds-to-probability relationship is nonlinear and depends on the starting probability. Step 4, the fit's overall quality. McFadden's pseudo-R² is 0.0173, small, honestly reported, the classification counterpart of Worked Example 7's own small R². At a 0.5 threshold, the model classifies 55.69% of the 2,880 rows correctly, against the 49.20% base rate of always guessing "up." Step 5, the verdict. A real, statistically significant, economically modest lift, three of four style factors carry genuine, if small, sign-predictive information; you would still be wrong 44.3% of the time, and any claim beyond "small, real edge" would be over-reading a pseudo-R² of 0.017.


Practice set

Work each problem fully before reading its solution. Every problem uses a different data slice from every Worked Example above, so you cannot simply recall an answer instead of running the reasoning yourself.

P1 (guided). Choosing the right scipy call. You have two matched-by-day return series for the same 250 trading days. Which scipy.stats function is correct, and what must you pass to signal that they are matched?

Solution. scipy.stats.ttest_rel(series_a, series_b), a paired test, because the two series share the same 250 days and are therefore not independent observations. No special flag is needed beyond calling ttest_rel instead of ttest_ind; the function's whole design assumes the two input arrays are already aligned index-for-index (day 1 with day 1, day 2 with day 2, and so on), get that alignment right before the call, since the function has no way to check it for you.

P2 (guided). The Welch default. What does scipy.stats.ttest_ind(a, b) assume if you do not pass equal_var, and why is that the riskier choice?

Solution. It defaults to equal_var=True (Student's t, assuming the two groups share a population variance). This is riskier because that assumption is rarely verified in practice, and QM1.02 names Welch's version (equal_var=False) as the safer default precisely because it does not require it, the expert habit is to write equal_var=False explicitly, every time, unless equal variances have actually been tested and confirmed.

P3. Spotting pseudo-replication. A dataset has ten stores, each reporting monthly revenue for twenty-four months, and an analyst runs ttest_ind comparing "region A's 120 monthly rows" against "region B's 120 monthly rows." What is the actual number of independent units being compared, and why does it matter?

Solution. The actual number of independent units is the number of stores, not the number of monthly rows, if region A has five stores and region B has five stores, this is fundamentally a five-versus-five comparison, not a 120-versus-120 one, because each store's twenty-four monthly figures are repeated measurements on the same underlying unit, not fresh independent draws. Treating it as 120-versus-120 manufactures an artificially small standard error (pseudo-replication, exactly §4's trap), which can turn a genuinely uncertain comparison into a falsely "significant" one. The fix: aggregate to one figure per store (e.g., each store's mean monthly revenue) before comparing, or use a model that explicitly accounts for the repeated-measures structure.

P4. Bootstrap SE scaling. Using a population with standard deviation 2.0%, what does theory predict for the standard error of the mean at n = 16 versus n = 64? What ratio should a correctly-implemented bootstrap simulation reproduce?

Solution. SE(n=16) = 2.0%/√16 = 0.5%; SE(n=64) = 2.0%/√64 = 0.25%. The ratio SE(16)/SE(64) = 2.0, quadrupling n from 16 to 64 should halve the standard error, and a correctly-implemented bootstrap_standard_error run at both n values on the same population should reproduce a ratio close to 2.0, exactly the check DA1.05-e01's harness runs.

P5. Reading a paired test's sign. A paired test of Portfolio X against Index Y returns t = 1.218 (positive). What does the positive sign tell you, on its own, about which one earned more on average?

Solution. A positive t-statistic from ttest_rel(a, b) means the mean of the differences (a − b) is positive, i.e., Portfolio X's average daily return was higher than Index Y's over this sample, here, however, p = 0.224, so this is not a statistically significant difference, and the sign alone (without also reporting the p-value) would overstate what the test actually found. Report both, always.

P6. F-test versus F-test. Explain the difference between the two-variance F-test in §6 and the F-test QM1.02 uses for a regression's overall significance, and state whether scipy.stats.f is used for both.

Solution. Both use the same F-distribution and the same scipy.stats.f object, but they test different ratios: §6's F-test compares two independent samples' raw variances (is σ₁² = σ₂²?), while a regression's overall F-test (produced automatically by statsmodels' .fvalue/.f_pvalue) compares explained variance to unexplained variance within one fitted model (are all the slopes jointly zero?). scipy.stats.f.cdf/.ppf can compute either tail probability once you have the right F-statistic and degrees of freedom; the distribution object is shared, the two questions are not.

P7. f_oneway is not a variance test. A learner, needing to compare two groups' variances and finding no scipy function named for it, reaches for scipy.stats.f_oneway(a, b) instead. What is wrong with this substitution?

Solution. f_oneway is one-way ANOVA, testing whether two or more groups' means differ, an entirely different null hypothesis from "do these two groups have equal variances?" Running it in place of a variance test answers a question the analyst did not ask and silently skips the one they did; the correct move is §6's manual F-ratio-plus-stats.f.cdf construction (or scipy.stats.levene, a variance-equality test scipy does provide, though not framed as QM1.02's exact F-ratio).

P8. Chi-square, correctly specified. A learner runs scipy.stats.chi2_contingency(table) without setting correction. What is the default, and why does this module's own exercises set correction=False explicitly?

Solution. The default is correction=True, which applies Yates' continuity correction (a small adjustment, mainly relevant for very small expected counts in a 2×2 table). This module sets correction=False explicitly to match QM1.02's own uncorrected chi-square formula exactly, not because the correction is wrong in general, but because reproducing the exact hand-derivable number, matching the theory this module cross-links, requires turning it off; a real analyst choosing between the two should say out loud which one they picked and why.

P9. Adjusted R², recomputed. A regression on n = 2,880 observations with 4 predictors reports R² = 0.031470. Compute adjusted R² by hand from QM1.02's own formula and confirm it against statsmodels' reported 0.030122.

Solution. Adjusted R² = 1 − (1 − R²)(n − 1)/(n − k − 1) = 1 − (1 − 0.031470)(2879/2875) = 1 − (0.968530)(1.001391) = 1 − 0.969878 = 0.030122, matching statsmodels' own reported figure exactly. The "Adj. R-squared" line in a printout is never a separate estimate; it is always this exact formula, applied to the R² printed just above it.

P10. Reading a mixed-significance multiple regression. A four-predictor OLS fit has a strong overall F (p < 0.0001) and clean VIFs (all near 1.0), but one of the four coefficients has p = 0.48. What is the most defensible conclusion about that one predictor?

Solution. With VIFs clean, the insignificance is not a multicollinearity artifact (unlike QM1.02's own credit-spread capstone, where high VIFs explained a weak individual t-stat despite a strong overall model), here, the honest reading is that this predictor genuinely carries little to no independent linear relationship with the outcome in this sample, once the other three predictors are already in the model. The overall F being strong says only that at least one of the four slopes is non-zero; it does not rescue this specific one.

P11. RESET, before and after adding predictors. A single-predictor regression passes RESET (p = 0.628); the same outcome regressed on four predictors instead fails RESET (p = 0.00355). Does this mean the single-predictor model was "more correct"?

Solution. No. RESET tests whether the fitted model as specified is missing curvature its own fitted values would reveal, it is a test of a specific specification, not a ranking of specifications. Adding predictors changed the functional form being tested, and the four-predictor model's own combination of fitted values happens to expose a missing nonlinearity or interaction the simpler model's fitted values did not surface. The correct next step is to investigate what curvature or interaction the four-predictor RESET is flagging (a squared term, a cross-product of two factors), not to retreat to the simpler, RESET-passing model as if passing were proof of correctness, a model can pass RESET and still be missing an omitted variable entirely, since RESET only checks functional form, never omission.

P12. Durbin–Watson, sorted correctly and incorrectly. A panel of 60 tickers × 48 months is stored sorted by date, then ticker, within each date. An analyst computes Durbin–Watson directly on the residuals in that row order. What is wrong, and what is the fix?

Solution. In that row order, consecutive rows are almost always two different tickers on the same date, not one ticker's own two consecutive months, so the Durbin–Watson statistic is testing "correlation between different tickers' same-month residuals," not the intended "does this series' error at t correlate with its error at t − 1." The fix: sort the data by ticker, then by date, so each ticker's own forty-eight-month run of residuals sits contiguously, and compute Durbin–Watson on that ordering instead, only then is the statistic answering the question it was designed to answer.

P13. Odds ratio, read correctly. A logistic regression reports a coefficient of 0.4055 on a predictor. State the odds ratio and the one-sentence correct interpretation.

Solution. Odds ratio = e^0.4055 ≈ 1.50. Correct reading: a one-unit increase in this predictor multiplies the odds of the outcome by about 1.5, holding every other included predictor constant, it does not mean "a 50% higher probability of the outcome," since the odds-to-probability relationship is nonlinear and depends on the baseline probability.

P14. Pseudo-R² is not R². A colleague sees a logistic regression's McFadden pseudo-R² of 0.15 and says "that's a weak fit, an OLS regression with R² = 0.15 would be considered weak too, so this is the same weakness." What is imprecise about that comparison?

Solution. McFadden's pseudo-R² is not on the same numeric scale as OLS's R², it is built from log-likelihoods (1 − LL(model)/LL(null)), not from a variance-explained ratio, and a McFadden pseudo-R² in the 0.15–0.3 range is often considered a good fit in applied logistic regression, unlike an OLS R² of 0.15, which is usually considered weak. The numbers are not directly comparable across the two model types, even when they happen to look similar; state which kind of R² you are quoting every time.

P15. Base-rate baseline, not 50%. A binary classifier achieves 62% accuracy on a dataset where the positive class occurs 58% of the time. Is 62% accuracy meaningful?

Solution. Barely, if at all, the "always guess the majority class" baseline here is 58% accuracy (by always predicting the more common outcome), not 50%. A classifier's 62% only beats that honest baseline by 4 percentage points, a far less impressive lift than comparing against a naive 50% coin-flip baseline would suggest. Always state the actual base-rate baseline for the specific dataset at hand before calling any accuracy figure meaningful.

P16. Sample size and the F-test's power. Two funds show sample standard deviations of 2.1% and 3.4% over 12 months each (QM1.02's own example, which fails to reject equal variances at F = 2.621, p = 0.125). If an identical variance ratio were instead observed over 250 months each, would you expect the same "fail to reject" verdict? Why?

Solution. No, with far more observations, the same F-ratio of 2.621 would very likely become statistically significant, because the F critical value shrinks toward 1 as both degrees of freedom grow, and a fixed ratio is easier to distinguish from 1 with more data (exactly Worked Example 5's F ≈ 1.29 becoming significant at n = 499 a side, despite being a much smaller ratio than QM1.02's own 2.621 at n = 12). Sample size changes what counts as detectable, never what the true underlying ratio actually is.

P17. AI-drafted code, reviewed. An AI assistant, asked to "compare two groups' means," returns exactly this: t, p = scipy.stats.ttest_ind(group_a, group_b). Using this module's own review checklist (the closing ai-augment-json callout), what is missing, and is the code wrong?

Solution. equal_var was left at its default (True), silently choosing Student's t over the safer Welch's default this module names explicitly, not necessarily wrong (if the two groups genuinely do share a variance, the two tests agree closely), but an unverified assumption baked in silently, exactly the class of AI-drafted bug that produces a clean, plausible number with nothing flagging the gap. The fix: scipy.stats.ttest_ind(group_a, group_b, equal_var=False), or first running §6's variance-equality check and only using equal_var=True if that check actually passes.

P18. Cross-check against QM1.02's own worked power calculation. QM1.02 computes, by hand, the power to detect a true mean of 0.5 against H0: μ = 0 (one-tailed, α = 0.05, n = 40, σ = 1.5 known), getting 0.678. Using scipy.stats.norm, how would you reproduce this number in code, and what does agreement (or disagreement) between the two tell you?

Solution. Compute the standard error (1.5/√40 = 0.2372), the critical sample mean (0 + 1.6449 × 0.2372 = 0.3901, using stats.norm.ppf(0.95) for the one-tailed 5% z-critical value), and then the power as 1 - stats.norm.cdf((0.3901 - 0.5) / 0.2372), which returns 0.678, matching QM1.02's hand calculation exactly. Agreement confirms the code faithfully implements the same formula QM1.02 derived; this kind of cross-check, reproducing an already-hand-verified number in code before trusting a new one, is a cheap, valuable habit anywhere this module's functions are new to you.


Applied mini-project

Run a five-question statistical brief against two of this module's own bundled datasets.

Using this module's code pack (DA1.05), open a fresh notebook context and answer all five questions below, each with real code run against the bundled files (nse-eod/us-eod plus one of india-fundamentals/sec-fundamentals or synthetic-factor-panel, your choice), not a toy example:

  1. Sampling. Pick one ticker from nse-eod or us-eod you have not used above. Simulate its sampling distribution of the mean at two different sample sizes (at least a 4× ratio, e.g., n = 20 and n = 80) with a seeded bootstrap, and state the ratio of standard errors you observe versus the ratio QM1.01's formula predicts.
  2. The right two-sample test, chosen and defended. Pick two genuinely independent groups (e.g., two different tickers' return series, or two markets' fundamentals) and state, in one sentence, why they are independent rather than paired: before running anything. Then run the correct test.
  3. A full OLS fit, read in QM1.02's five-step order. Regress one numeric column on at least two predictors from any bundled dataset. Walk through: (i) do the signs make sense; (ii) which coefficients clear significance; (iii) does the overall F agree with the individual t-stats; (iv) adjusted R² versus raw R²; (v) run all four diagnostics and report each one's verdict, not just the ones that happen to look clean.
  4. One deliberately broken diagnostic, on purpose. Take your Question 3 model's residuals and compute Durbin–Watson twice: once on the data in its original file order, and once after sorting it correctly (by whatever the natural entity, e.g., ticker) and time. Report both numbers and state, in one sentence, which one is actually answering the serial-correlation question and why.
  5. Statistical versus economic significance, stated together. For any one result from Questions 2–3, report both its p-value and its effect size (r², a coefficient's economic magnitude, or an accuracy lift over base rate) in the same sentence, and say explicitly whether the result is (a) both statistically and economically meaningful, (b) statistically significant but economically small, or (c) neither.

Self-check rubric, score yourself out of 10, pass at 8/10 with no zero on any item:

  • (2 pts) Question 1's observed SE ratio is within about 20% of the theoretical prediction, and the seed/trial count used is stated so the result is reproducible.
  • (2 pts) Question 2's independence claim is stated and justified before the test is run, not inferred afterward from the result, and the correct function (ttest_ind with an explicit equal_var choice) is used.
  • (2 pts) Question 3 runs all four diagnostics (Breusch–Pagan, VIF, RESET, Durbin–Watson) and reports each one's actual number and verdict, including any that come back "clean", not just the ones that flag a problem.
  • (2 pts) Question 4 explicitly contrasts the two Durbin–Watson orderings and correctly identifies which one is the meaningful one and why.
  • (2 pts) Question 5 states both a p-value and an effect-size/economic-magnitude figure in the same sentence, and correctly classifies the result as (a), (b), or (c).

Lab: open the DA1.05 pack in this node's Practice tab and run all 9 graded exercises (DA1.05-e01 through DA1.05-e09) to green (hidden tests included) before starting this project; every one of the five questions above deliberately reuses one of those nine exercises' shape, so passing the pack first means this project is a genuine application, not a first attempt.


Reading & resources

Everything below supports what this module teaches; none of it substitutes for running real code against real (if fictional) data yourself.

  • scipy.stats official documentation (docs.scipy.org), the authoritative reference for every function this module calls (ttest_1samp, ttest_ind, ttest_rel, chi2_contingency, pearsonr, and the f/chi2/norm distribution objects), including every default argument worth reading before you rely on it. [Free] [Beginner–Advanced]
  • statsmodels official documentation and example gallery (statsmodels.org), the authoritative reference for OLS, Logit, .summary()'s every field, and the stats.diagnostic/stats.outliers_influence modules this module's §9 depends on. [Free] [Beginner–Advanced]
  • Wes McKinney, Python for Data Analysis: the standard reference for the pandas fluency (read_csv, groupby, time-indexed slicing) this module assumes from DA1.02/DA1.03. [Paid] [Beginner–Intermediate]
  • Jonathan Taylor and collaborators, Statistical Learning (Stanford, freely available course materials), a rigorous, freely-available treatment of regression diagnostics and logistic regression that goes one level deeper than this module's applied "read the printout" framing. [Free] [Intermediate–Advanced]
  • Josef Perktold et al., the statsmodels diagnostic-testing papers/documentation on Breusch–Pagan, RESET, and VIF, for the exact numerical implementation this module's functions call, when you want to verify a result down to the algorithm level rather than the formula level. [Free] [Advanced]
  • William H. Greene, Econometric Analysis: the standard graduate reference behind essentially every diagnostic test named in QM1.02 and implemented here, for when "the theory this module cites" is not enough and you want the full derivation and its edge cases. [Paid] [Advanced]
  • The synthetic-factor-panel and synthetic-portfolio datasets' own build notes (app/public/datasets/MANIFEST.json, and tools/build_datasets.py if you want to see exactly how the seeded signal was constructed), reading how a dataset with "a genuine, reproducible signal to recover" was actually built is a strong way to build intuition for why §7–10's results come out the way they do. [Free] [Intermediate]

Do this, not just read. Take any one function from §3–10 and deliberately misuse it once, on purpose, before moving on: call ttest_ind with equal_var left at its default on a pair you know has unequal variances and watch the p-value shift when you add equal_var=False; fit an OLS model without sm.add_constant and watch every coefficient change; compute Durbin–Watson on a panel in its original file order, then again correctly sorted, and watch whether the number moves. Watching a diagnostic change, or fail to change, which is its own lesson, for a reason you predicted before running it is worth more than reading another paragraph about the rule in the abstract.


Teach it back & journal

Feynman prompt. Explain to someone who has just finished QM1.02 by hand why scipy.stats.ttest_ind(a, b) (run with no arguments beyond the two arrays) can return a clean, confident, wrong-shaped answer without ever raising an error. Use the pseudo-replication trap (§4) as your concrete example: fifteen companies' four fiscal years, pooled into "sixty independent" rows, producing an almost perfectly flat null result that looked, on the surface, exactly like an honest one. Then explain, in the same breath, why the fix is never a different function call, it is deciding, before any function runs, what the actual independent unit in your data is. If your listener can, unprompted, predict that sm.OLS(y, X).fit() without sm.add_constant(X) is the same shape of silent bug, a clean printout hiding a wrong setup, you have installed this module's central discipline.

Journal prompt. For one sitting, take every function this module taught you (ttest_1samp, ttest_ind, ttest_rel, the F-test, chi2_contingency, pearsonr, OLS, the four diagnostics, Logit) and, for each one, write one sentence stating the single assumption a real analyst must check before trusting its output, the assumption the function itself has no way to verify. Which one of those nine assumptions do you find yourself most tempted to skip when you are in a hurry, and why? Then write a short paragraph on this: QM1.02 opened by warning that a fluent regression table can be quietly wrong in a way that reads exactly like a correct one, this module is where that warning stops being about AI output specifically and becomes about your own code, the moment you are the one who forgot equal_var=False, or the intercept, or which way to sort a panel before computing Durbin–Watson. What is the one habit from this module, checking independence before the test, adding the constant before the fit, sorting before the diagnostic, you will make automatic starting this week?

Advertisement

The gate

The module's flashcards and its quiz are in the app. The quiz is the gate: you need 85% to pass it, and the next module opens when you do.

Terms this module defines