numpy, scipy, pandas or
statistics. Only math and fractions from the standard
library appear, and every distribution, every tail area and every algorithm is written out.
| # | Program | The statistical point |
|---|---|---|
| 0 | The tail areas (tails.py) |
\(t\), \(F\), \(\chi^{2}\) and normal \(p\) values with no table, for programs 8, 9, 11 and 12 |
| 1 | Sum and product of two matrices | shape conditions; multiplication does not commute |
| 2 | Determinant and inverse | cofactor expansion; the transpose inside the adjoint; detecting singularity |
| 3 | Four sorts, two searches | comparison counts, and why binary search needs sorted input |
| 4 | Median and mode | the even-\(n\) case; several modes; no mode at all |
| 5 | Frequency table and five summaries | the grouped formulae, and the grouping error they carry |
| 6 | Four moments, skewness, kurtosis | raw to central conversion, checked a second way |
| 7 | Random numbers from five distributions | inverse transform, Box–Muller, Knuth's Poisson — and verifying them |
| 8 | Binomial, Poisson, negative binomial fits | over-dispersion; pooling classes; degrees of freedom after estimation |
| 9 | Normal, exponential, Cauchy fits | expected frequencies from the c.d.f.; fitting a distribution with no moments |
| 10 | Correlation and both regression lines | \(b_{yx}b_{xy} = r^{2}\); why there are two lines |
| 11 | Tests for means, variances, correlations | seven tests, the tail areas computed, and the order to run them in |
| 12 | One-way and two-way analysis of variance | the correction factor route, and error by subtraction as a check |
Every output shown below is the output these programs actually produce.
This page is built by running each programme, as listed, in Python 3.11.15; the text under
each is what it printed, unchanged. Save tails.py in the same folder as the
others, since programs 8, 9, 11 and 12 import it.
Without a statistical package or a printed table, write a module tails.py
that returns \(P(|T_\nu| > t)\), \(P(F_{d_1,d_2} > f)\), \(P(\chi^{2}_\nu > q)\) and
\(P(|Z| > z)\), and check it at the published 5% points \(t_{10} = 2.228\),
\(F_{5,10} = 3.326\), \(\chi^{2}_{8} = 15.507\) and \(z = 1.959964\).
Compute the four tail areas that programs 8, 9, 11 and 12 need, once, so that each of them can import them. Write this file first and keep it beside the others; in the examination it is the single piece of code worth having memorised in outline.
Four programs need \(P(|T| > t)\), \(P(F > f)\), \(P(\chi^{2} > q)\) or \(P(|Z| > z)\), and no package may supply them. Two facts make it possible at all:
\[ P\!\left(|T_\nu| > t\right) = I_{\frac{\nu}{\nu+t^{2}}}\!\left(\frac{\nu}{2}, \frac{1}{2}\right), \qquad P\!\left(F_{d_1,d_2} > f\right) = I_{\frac{d_2}{d_2+d_1f}}\!\left(\frac{d_2}{2}, \frac{d_1}{2}\right), \]so a single incomplete beta function \(I_x(a,b)\) delivers both the \(t\) and the \(F\) tail; and the \(\chi^{2}\) tail is the incomplete gamma function, which its own series and continued fraction supply.
| Function or statement | What it does |
|---|---|
math.lgamma(x) | \(\ln\Gamma(x)\), for the front factors without overflow |
math.exp, math.log | the front factors, built on the log scale |
math.erfc(x) | the complementary error function, \(1 - \operatorname{erf}(x)\) |
for m in range(1, 300): ... break | iterate the continued fraction until it has converged |
if __name__ == "__main__": | run the check only when the file is run, not when another program imports it |
# tails.py -- the four tail areas every test below needs, written out because
# no statistical package may be used. Save this file once and import from it.
import math
# Step 1: The continued fraction for the incomplete beta
def betacf(a, b, x):
"""Continued fraction for the incomplete beta function, by Lentz's method."""
tiny = 1e-300
qab, qap, qam = a + b, a + 1, a - 1
c, d = 1.0, 1 - qab * x / qap
d = 1 / (tiny if abs(d) < tiny else d)
h = d
for m in range(1, 300):
m2 = 2 * m
aa = m * (b - m) * x / ((qam + m2) * (a + m2))
d = 1 + aa * d
c = 1 + aa / c
d = 1 / (tiny if abs(d) < tiny else d)
h *= d * (tiny if abs(c) < tiny else c)
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
d = 1 + aa * d
c = 1 + aa / c
d = 1 / (tiny if abs(d) < tiny else d)
de = d * c
h *= de
if abs(de - 1) < 3e-16:
break
return h
# Step 2: The regularised incomplete beta
def betainc(a, b, x):
"""The regularised incomplete beta function I_x(a, b)."""
if x <= 0:
return 0.0
if x >= 1:
return 1.0
lf = math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b)
if x < (a + 1) / (a + b + 2):
return math.exp(lf + a * math.log(x) + b * math.log(1 - x)) * betacf(a, b, x) / a
return 1 - math.exp(lf + b * math.log(1 - x) + a * math.log(x)) * betacf(b, a, 1 - x) / b
# Step 3: The t and F tails from it
def t_two_sided(t, df):
"""P(|T_df| > |t|)."""
return betainc(df / 2, 0.5, df / (df + t * t))
def F_upper(f, d1, d2):
"""P(F_{d1,d2} > f)."""
return 1.0 if f <= 0 else betainc(d2 / 2, d1 / 2, d2 / (d2 + d1 * f))
# Step 4: The chi-square tail from the incomplete gamma
def chi2_upper(q, df):
"""P(chi^2_df > q): the series for the lower gamma when q is small, and the
continued fraction for the upper gamma when it is large, so that a tiny tail
is computed directly instead of as 1 minus a number close to 1."""
a, xq = df / 2, q / 2
if xq <= 0:
return 1.0
front = math.exp(-xq + a * math.log(xq) - math.lgamma(a))
if xq < a + 1:
term, s, k = 1.0 / a, 1.0 / a, 1
while abs(term) > 1e-15 * abs(s) and k < 10000:
term *= xq / (a + k)
s += term
k += 1
return 1 - s * front
tiny = 1e-300 # Lentz's method, as in betacf
b = xq + 1 - a
c, d = 1 / tiny, 1 / b
h = d
for i in range(1, 10000):
an = -i * (i - a)
b += 2
d = an * d + b
d = 1 / (tiny if abs(d) < tiny else d)
c = b + an / c
c = tiny if abs(c) < tiny else c
de = d * c
h *= de
if abs(de - 1) < 3e-16:
break
return front * h
# Step 5: The normal tail
def normal_two_sided(z):
"""P(|Z| > |z|) for the standard normal."""
return math.erfc(abs(z) / math.sqrt(2))
# Step 6: Check all four against the tables
if __name__ == "__main__":
# three published values, as a check that the functions are right
print(f"P(|t_10| > 2.228) = {t_two_sided(2.228, 10):.4f} (table: 0.0500)")
print(f"P(F_(5,10) > 3.326) = {F_upper(3.326, 5, 10):.4f} (table: 0.0500)")
print(f"P(chi^2_8 > 15.507) = {chi2_upper(15.507, 8):.4f} (table: 0.0500)")
print(f"P(|Z| > 1.959964) = {normal_two_sided(1.959964):.4f} (table: 0.0500)")
Saved as tails.py and run with python3 tails.py, it printed:
P(|t_10| > 2.228) = 0.0500 (table: 0.0500)
P(F_(5,10) > 3.326) = 0.0500 (table: 0.0500)
P(chi^2_8 > 15.507) = 0.0500 (table: 0.0500)
P(|Z| > 1.959964) = 0.0500 (table: 0.0500)
chi2_upper computed every
tail as 1 minus the series for the lower part. Below about \(10^{-13}\) that subtraction
loses all its digits: it printed \(p = 1.332\times10^{-15}\) for the exponential fit in
Practical 9, where the true value is \(3.99\times10^{-56}\), and \(5.773\times10^{-15}\) for
the Poisson fit in Practical 8, where it is \(7.889\times10^{-15}\). Step 4 now uses the
continued fraction for the upper tail. Rechecked against SciPy for \(\nu\) from 1 to 100,
all four functions agree to a relative error below \(2\times10^{-13}\). No decision changes:
both fits are rejected either way.
All four functions return 0.0500 at the published 5% points, so programs 8, 9, 11 and 12 take their \(p\) values from this module instead of a table.
Without packages, write a program to find \(A + B\), \(AB\) and \(BA\) for
\[ A = \begin{pmatrix}2 & 1 & 1\\ 1 & 3 & 2\\ 1 & 0 & 4\end{pmatrix}, \qquad B = \begin{pmatrix}1 & 0 & 2\\ 2 & 1 & 0\\ 0 & 3 & 1\end{pmatrix}, \]and decide whether \(AB = BA\).
Add and multiply two matrices, and show that matrix multiplication does not commute.
len(M), the columns len(M[0]).What has to be written out. Addition is elementwise and needs the two matrices to have the same shape. Multiplication needs the columns of \(A\) to equal the rows of \(B\), and each entry is an inner product:
\[ (AB)_{ij} = \sum_{k=1}^{c}A_{ik}B_{kj}. \]Both shape conditions are checked before any arithmetic, because a silent shape mismatch in Python produces a wrong answer rather than an error.
| Function or statement | What it does |
|---|---|
[[... for j in ...] for i in ...] | a list comprehension that builds the matrix row by row |
sum(A[i][k] * B[k][j] for k in range(ca)) | the inner product for one entry |
raise ValueError(...) | stop with a message when the shapes do not fit |
f"{v:6.2f}" | print a number in 6 columns with 2 decimals |
== on two lists | true only if every entry agrees |
# Practical 1 -- sum and product of two matrices, no packages
# Step 1: Check the shapes
def shape(M):
return len(M), len(M[0])
# Step 2: Add elementwise
def add(A, B):
if shape(A) != shape(B):
raise ValueError("addition needs matrices of the same shape")
return [[A[i][j] + B[i][j] for j in range(len(A[0]))] for i in range(len(A))]
# Step 3: Multiply by inner products
def multiply(A, B):
ra, ca = shape(A)
rb, cb = shape(B)
if ca != rb:
raise ValueError("columns of A must equal rows of B")
return [[sum(A[i][k] * B[k][j] for k in range(ca)) for j in range(cb)]
for i in range(ra)]
# Step 4: Print a matrix
def show(name, M):
print(name)
for row in M:
print(" " + " ".join(f"{v:6.2f}" for v in row))
# Step 5: Enter the two matrices
A = [[2, 1, 1], [1, 3, 2], [1, 0, 4]]
B = [[1, 0, 2], [2, 1, 0], [0, 3, 1]]
# Step 6: Form A + B, AB and BA, and compare AB with BA
show("A + B", add(A, B))
show("A x B", multiply(A, B))
show("B x A", multiply(B, A))
print("A x B equals B x A?", multiply(A, B) == multiply(B, A))
Saved as p1.py and run with python3 p1.py, it printed:
A + B
3.00 1.00 3.00
3.00 4.00 2.00
1.00 3.00 5.00
A x B
4.00 4.00 5.00
7.00 9.00 4.00
1.00 12.00 6.00
B x A
4.00 1.00 9.00
5.00 5.00 4.00
4.00 9.00 10.00
A x B equals B x A? False
\(A + B\), \(AB\) and \(BA\) are printed above. The program prints False for
\(AB = BA\): the \((1,3)\) entry, for one, is 5 in \(AB\) and 9 in \(BA\). Matrix
multiplication does not commute, even for two square matrices of the same order.
For the matrix \(A\) of Practical 1, find \(\det(A)\) by cofactor expansion and \(A^{-1}\) by the adjoint, in exact fractions, and check that \(AA^{-1} = I\). Then show that
\[ S = \begin{pmatrix}1 & 2 & 3\\ 2 & 4 & 6\\ 1 & 0 & 1\end{pmatrix} \]has no inverse.
Compute a determinant by cofactor expansion and an inverse by the adjoint, in exact arithmetic, and detect a singular matrix.
Fraction.The two formulae. Expanding along the first row,
\[ \det(A) = \sum_{j=1}^{n}(-1)^{1+j}a_{1j}\det\!\left(M_{1j}\right), \]where \(M_{1j}\) is \(A\) with row 1 and column \(j\) deleted; and
\[ A^{-1} = \frac{1}{\det(A)}\operatorname{adj}(A), \qquad \operatorname{adj}(A) = \left[(-1)^{i+j}\det\!\left(M_{ij}\right)\right]^{\mathsf T}. \]The transpose in the adjoint is the step that is most often dropped, and for a symmetric matrix dropping it makes no difference — which is exactly why the test matrix below is not symmetric.
Using Fraction rather than float makes the check
\(AA^{-1} = I\) come out exactly, with no rounding to interpret.
| Function or statement | What it does |
|---|---|
from fractions import Fraction | exact rational arithmetic |
Fraction(cof[j][i], d) | the \((i,j)\) entry of the inverse: note the swapped indices, which is the transpose |
det(minor(M, 0, j)) | a function that calls itself on a smaller matrix (recursion) |
(-1) ** (i + j) | the sign of a cofactor |
# Practical 2 -- determinant by cofactor expansion and inverse by the adjoint
from fractions import Fraction
# Step 1: Delete a row and a column
def minor(M, i, j):
return [[M[r][c] for c in range(len(M)) if c != j]
for r in range(len(M)) if r != i]
# Step 2: Expand the determinant along row 1
def det(M):
n = len(M)
if n == 1:
return M[0][0]
if n == 2:
return M[0][0] * M[1][1] - M[0][1] * M[1][0]
return sum((-1) ** j * M[0][j] * det(minor(M, 0, j)) for j in range(n))
# Step 3: Invert by the transposed cofactors
def inverse(M):
d = det(M)
if d == 0:
raise ValueError("matrix is singular -- no inverse")
n = len(M)
cof = [[(-1) ** (i + j) * det(minor(M, i, j)) for j in range(n)] for i in range(n)]
# the adjoint is the TRANSPOSE of the cofactor matrix
return [[Fraction(cof[j][i], d) for j in range(n)] for i in range(n)]
# Step 4: Print a matrix of fractions
def show(name, M):
print(name)
for row in M:
print(" " + " ".join(f"{str(v):>8}" for v in row))
# Step 5: Find det(A) and the inverse
A = [[2, 1, 1], [1, 3, 2], [1, 0, 4]]
print("det(A) =", det(A))
Ainv = inverse(A)
show("A inverse", Ainv)
# Step 6: Check that A times its inverse is I
# check: A times its inverse must be the identity, exactly
prod = [[sum(Fraction(A[i][k]) * Ainv[k][j] for k in range(3)) for j in range(3)]
for i in range(3)]
show("A x A inverse", prod)
# Step 7: Try a singular matrix
S = [[1, 2, 3], [2, 4, 6], [1, 0, 1]]
print("det(S) =", det(S), "-- row 2 is twice row 1, so S is singular")
Saved as p2.py and run with python3 p2.py, it printed:
det(A) = 19
A inverse
12/19 -4/19 -1/19
-2/19 7/19 -3/19
-3/19 1/19 5/19
A x A inverse
1 0 0
0 1 0
0 0 1
det(S) = 0 -- row 2 is twice row 1, so S is singular
\(\det(A) = 19\), and every entry of \(A^{-1}\) is a multiple of \(1/19\); the product \(AA^{-1}\) is the identity exactly. \(\det(S) = 0\) because its second row is twice its first, so \(S\) is singular and has no inverse.
Sort the ten numbers 42, 17, 93, 8, 55, 23, 71, 4, 66, 30 by bubble, insertion, merge and quick sort, counting the comparisons where the method allows it; then search the sorted list for 66 and for 50 by linear and by binary search, and compare the work each one does.
Implement bubble, insertion, merge and quick sort, and linear and binary search, and count the comparisons each one uses.
| Method | Idea | Comparisons |
|---|---|---|
| Bubble | repeatedly swap adjacent out-of-order pairs | \(O(n^{2})\); \(O(n)\) if already sorted, using the early exit |
| Insertion | grow a sorted prefix, sliding each new key back into it | \(O(n^{2})\) worst, \(O(n)\) best — usually beats bubble |
| Merge | split, sort each half, merge | \(O(n\log n)\) always; needs extra space |
| Quick | partition about a pivot, recurse on each side | \(O(n\log n)\) expected, \(O(n^{2})\) on a bad pivot |
| Linear search | scan | \(O(n)\); works on unsorted data |
| Binary search | halve the interval | \(O(\log n)\); requires sorted data |
| Function or statement | What it does |
|---|---|
a = a[:] | work on a copy, so the caller's list is not changed |
a[j], a[j + 1] = a[j + 1], a[j] | swap two entries in one statement |
return a, comps | return two values, as a tuple |
(lo + hi) // 2 | the middle position, by integer division |
enumerate(a) | each position together with its value |
# Practical 3 -- four sorts and two searches, each counting its own work
# Step 1: Bubble sort, counting comparisons
def bubble(a):
a = a[:]
comps = 0
for i in range(len(a) - 1):
swapped = False
for j in range(len(a) - 1 - i):
comps += 1
if a[j] > a[j + 1]:
a[j], a[j + 1] = a[j + 1], a[j]
swapped = True
if not swapped:
break
return a, comps
# Step 2: Insertion sort, counting comparisons
def insertion(a):
a = a[:]
comps = 0
for i in range(1, len(a)):
key = a[i]
j = i - 1
while j >= 0:
comps += 1
if a[j] <= key:
break
a[j + 1] = a[j]
j -= 1
a[j + 1] = key
return a, comps
# Step 3: Merge sort
def merge_sort(a):
if len(a) <= 1:
return a
mid = len(a) // 2
left, right = merge_sort(a[:mid]), merge_sort(a[mid:])
out, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
out.append(left[i]); i += 1
else:
out.append(right[j]); j += 1
return out + left[i:] + right[j:]
# Step 4: Quick sort
def quick_sort(a):
if len(a) <= 1:
return a
pivot = a[len(a) // 2]
lo = [v for v in a if v < pivot]
eq = [v for v in a if v == pivot]
hi = [v for v in a if v > pivot]
return quick_sort(lo) + eq + quick_sort(hi)
# Step 5: Linear and binary search, counting comparisons
def linear_search(a, key):
for i, v in enumerate(a):
if v == key:
return i, i + 1 # position, comparisons used
return -1, len(a)
def binary_search(a, key):
lo, hi, comps = 0, len(a) - 1, 0
while lo <= hi:
mid = (lo + hi) // 2
comps += 1
if a[mid] == key:
return mid, comps
if a[mid] < key:
lo = mid + 1
else:
hi = mid - 1
return -1, comps
# Step 6: Sort the data four ways and compare
data = [42, 17, 93, 8, 55, 23, 71, 4, 66, 30]
b, cb = bubble(data)
i_, ci = insertion(data)
print("bubble ", b, "comparisons:", cb)
print("insertion", i_, "comparisons:", ci)
print("merge ", merge_sort(data))
print("quick ", quick_sort(data))
print("all four agree?", b == i_ == merge_sort(data) == quick_sort(data))
# Step 7: Search the sorted list
srt = b
print("linear search for 66:", linear_search(srt, 66))
print("binary search for 66:", binary_search(srt, 66))
print("binary search for 50:", binary_search(srt, 50))
Saved as p3.py and run with python3 p3.py, it printed:
bubble [4, 8, 17, 23, 30, 42, 55, 66, 71, 93] comparisons: 44
insertion [4, 8, 17, 23, 30, 42, 55, 66, 71, 93] comparisons: 29
merge [4, 8, 17, 23, 30, 42, 55, 66, 71, 93]
quick [4, 8, 17, 23, 30, 42, 55, 66, 71, 93]
all four agree? True
linear search for 66: (7, 8)
binary search for 66: (7, 2)
binary search for 50: (-1, 4)
The comparison counts in the output are the point of the exercise: on the same ten numbers bubble sort used 44 comparisons and insertion sort 29, and binary search found the target in 2 comparisons where linear search needed 8. The search for 50 returns position \(-1\): it is not in the list, and binary search established that in 4 comparisons.
All four sorts give 4, 8, 17, 23, 30, 42, 55, 66, 71, 93. Insertion sort used fewer comparisons than bubble sort (29 against 44), and binary search found 66 at position 7 in 2 comparisons against linear search's 8 — but only because the list had been sorted first.
Find the median of 7, 3, 9, 3, 5, 8, 3, 9, 4 (odd \(n\)) and of 12, 4, 9, 4, 15, 7 (even \(n\)); find the mode of the first, of 2, 5, 2, 7, 5, 9, and of 4, 6, 9, 11.
Compute the median and the mode from first principles, and handle the two cases a library routine hides.
Median. Sort, then take the middle value if \(n\) is odd and the mean of the two middle values if \(n\) is even. Note the indices: for even \(n\) they are \(n/2 - 1\) and \(n/2\) in Python's zero-based numbering, not \(n/2\) and \(n/2 + 1\).
Mode. Build a frequency dictionary in one pass, then take every value attaining the maximum count. Two cases matter and both appear in the output:
| Function or statement | What it does |
|---|---|
sorted(a) | a sorted copy of the list |
n // 2, n % 2 | the middle position, and whether \(n\) is odd |
freq.get(v, 0) + 1 | add one to a value's count, starting from 0 |
max(freq.values()) | the largest count |
# Practical 4 -- median and mode of an array, written out
# Step 1: Median: sort, then take the middle
def median(a):
s = sorted(a)
n = len(s)
mid = n // 2
if n % 2:
return s[mid]
return (s[mid - 1] + s[mid]) / 2
# Step 2: Mode: count, then keep every value at the top count
def mode(a):
freq = {}
for v in a:
freq[v] = freq.get(v, 0) + 1
top = max(freq.values())
modes = sorted(k for k, c in freq.items() if c == top)
return modes, top, freq
# Step 3: Enter the test arrays
odd = [7, 3, 9, 3, 5, 8, 3, 9, 4]
even = [12, 4, 9, 4, 15, 7]
bimodal = [2, 5, 2, 7, 5, 9]
# Step 4: Medians for odd and even n
print("odd n =", len(odd), " median =", median(odd))
print("even n =", len(even), " median =", median(even))
# Step 5: Modes: one, two and none
m, c, f = mode(odd)
print("odd mode(s) =", m, "occurring", c, "times; frequencies", dict(sorted(f.items())))
m, c, f = mode(bimodal)
print("bimodal mode(s) =", m, "occurring", c, "times -- two modes, so the mode is not unique")
allsame = [4, 6, 9, 11]
m, c, _ = mode(allsame)
print("no repeats:", m, "each", c, "time -- the mode is undefined for this data, not 'all of them'")
Saved as p4.py and run with python3 p4.py, it printed:
odd n = 9 median = 5
even n = 6 median = 8.0
odd mode(s) = [3] occurring 3 times; frequencies {3: 3, 4: 1, 5: 1, 7: 1, 8: 1, 9: 2}
bimodal mode(s) = [2, 5] occurring 2 times -- two modes, so the mode is not unique
no repeats: [4, 6, 9, 11] each 1 time -- the mode is undefined for this data, not 'all of them'
The last line prints the four values, but the program's own message says what they mean: each occurs once, so the data has no mode.
The medians are 5 (odd \(n = 9\)) and 8.0 (even \(n = 6\), the mean of 7 and 9). The mode of the first array is 3, occurring 3 times. The second set is bimodal, with modes 2 and 5; in the last set no value repeats, so the mode is undefined.
Group the forty values below into the classes 10–20, 20–30, …, 50–60. From the table, find the mean, median, mode, variance and standard deviation; find the same from the raw values, and the grouping error in the mean.
23, 45, 12, 37, 52, 28, 41, 19, 33, 47, 55, 26, 38, 44, 31, 17, 49, 35, 22, 40, 29, 51, 36, 43, 25, 48, 32, 39, 27, 46, 34, 21, 42, 30, 53, 24, 37, 45, 33, 41
Build a grouped frequency table and compute the mean, median, mode, variance and standard deviation from it — then compute the same quantities from the raw values and measure the grouping error.
The grouped formulae. With class mid-points \(m_i\), frequencies \(f_i\) and \(n = \sum f_i\),
\[ \bar x = \frac{\sum f_im_i}{n}, \qquad s^{2} = \frac{\sum f_i\left(m_i - \bar x\right)^{2}}{n}, \] \[ \text{median} = L + \frac{n/2 - c}{f}\,h, \qquad \text{mode} = L + \frac{f_1 - f_0}{2f_1 - f_0 - f_2}\,h, \]where \(L\) is the lower boundary of the class containing the item, \(c\) the cumulative frequency before it, \(f\) its frequency, \(h\) the width, and \(f_0, f_1, f_2\) the frequencies of the class before the modal class, the modal class and the class after.
| Function or statement | What it does |
|---|---|
int((v - low) // width) | the class a value falls in |
[0] * k | a list of \(k\) zero counts |
zip(mids, counts) | pair each mid-point with its frequency |
counts.index(max(counts)) | the first class with the largest frequency |
math.sqrt | the standard deviation from the variance |
# Practical 5 -- frequency table, then mean, median, mode, variance, SD
# both from the raw values and from the grouped table, so the grouping error shows
import math
# Step 1: Enter the raw data
raw = [23, 45, 12, 37, 52, 28, 41, 19, 33, 47,
55, 26, 38, 44, 31, 17, 49, 35, 22, 40,
29, 51, 36, 43, 25, 48, 32, 39, 27, 46,
34, 21, 42, 30, 53, 24, 37, 45, 33, 41]
# Step 2: Count the classes
def freq_table(values, low, width, k):
edges = [low + i * width for i in range(k + 1)]
counts = [0] * k
for v in values:
idx = int((v - low) // width)
if idx == k: # the top edge belongs to the last class
idx = k - 1
counts[idx] += 1
return edges, counts
# Step 3: Grouped mean
def grouped_mean(edges, counts):
mids = [(edges[i] + edges[i + 1]) / 2 for i in range(len(counts))]
n = sum(counts)
return sum(m * f for m, f in zip(mids, counts)) / n, mids, n
# Step 4: Grouped median
def grouped_median(edges, counts):
n = sum(counts)
cum = 0
for i, f in enumerate(counts):
if cum + f >= n / 2:
L = edges[i]
h = edges[i + 1] - edges[i]
return L + (n / 2 - cum) / f * h
cum += f
# Step 5: Grouped mode
def grouped_mode(edges, counts):
i = counts.index(max(counts))
f1 = counts[i]
f0 = counts[i - 1] if i > 0 else 0
f2 = counts[i + 1] if i + 1 < len(counts) else 0
L = edges[i]
h = edges[i + 1] - edges[i]
return L + (f1 - f0) / (2 * f1 - f0 - f2) * h
# Step 6: Print the table and the grouped summaries
edges, counts = freq_table(raw, 10, 10, 5)
print("class frequency")
for i, f in enumerate(counts):
print(f" {edges[i]:2d} - {edges[i+1]:2d} {f:2d}")
print(" total ", sum(counts))
gm, mids, n = grouped_mean(edges, counts)
gmed = grouped_median(edges, counts)
gmode = grouped_mode(edges, counts)
gvar = sum(f * (m - gm) ** 2 for m, f in zip(mids, counts)) / n
print(f"grouped: mean {gm:.4f} median {gmed:.4f} mode {gmode:.4f} "
f" variance {gvar:.4f} sd {math.sqrt(gvar):.4f}")
# Step 7: The same summaries from the raw values
rm = sum(raw) / len(raw)
s = sorted(raw)
rmed = (s[19] + s[20]) / 2
rvar = sum((v - rm) ** 2 for v in raw) / len(raw)
print(f"raw: mean {rm:.4f} median {rmed:.4f} "
f" variance {rvar:.4f} sd {math.sqrt(rvar):.4f}")
print(f"grouping error in the mean: {gm - rm:+.4f}")
# Step 8: The tied modal class
# the two middle classes tie at 12, so the modal class is not unique --
# apply the formula to the SECOND of them and see what happens
i = 3
f1, f0, f2 = counts[i], counts[i-1], counts[i+1]
alt = edges[i] + (f1 - f0) / (2*f1 - f0 - f2) * 10
print(f"mode from the other tied class: {alt:.4f} -- the same boundary, 40")
Saved as p5.py and run with python3 p5.py, it printed:
class frequency
10 - 20 3
20 - 30 9
30 - 40 12
40 - 50 12
50 - 60 4
total 40
grouped: mean 36.2500 median 36.6667 mode 40.0000 variance 120.9375 sd 10.9972
raw: mean 35.7500 median 36.5000 variance 113.2375 sd 10.6413
grouping error in the mean: +0.5000
mode from the other tied class: 40.0000 -- the same boundary, 40
Two things the output shows that a formula sheet does not. First, the grouped mean is \(36.25\) against a raw mean of \(35.75\): grouping replaces every value by its class mid-point and the error does not vanish. Second, the two middle classes tie at 12 observations, so the modal class is not unique — and applying the formula to either of them returns the same number, \(40\), the boundary they share.
From the table: mean 36.25, median 36.67, mode 40, variance 120.94, standard deviation 11.00. From the raw values: mean 35.75, median 36.5, variance 113.24, standard deviation 10.64. Grouping has moved the mean by \(+0.50\) and inflated the variance; the mode is 40 whichever of the two tied classes is used.
For 12, 15, 11, 18, 22, 14, 16, 19, 13, 25, 17, 20, 14, 16, 21, find the first four raw moments, convert them to central moments, check the conversion by computing the central moments directly, and find \(\beta_1\), \(\gamma_1\), \(\beta_2\) and \(\gamma_2\).
Compute the first four raw moments, convert them to central moments, and from those the two shape coefficients.
The conversion, which is the examinable part. Writing \(m_r' = \frac{1}{n}\sum x_i^{r}\) for the raw moments about the origin,
\[ \mu_2 = m_2' - m_1'^{2}, \qquad \mu_3 = m_3' - 3m_1'm_2' + 2m_1'^{3}, \] \[ \mu_4 = m_4' - 4m_1'm_3' + 6m_1'^{2}m_2' - 3m_1'^{4}, \]and then
\[ \beta_1 = \frac{\mu_3^{2}}{\mu_2^{3}}, \quad \gamma_1 = \frac{\mu_3}{\mu_2^{3/2}}, \qquad \beta_2 = \frac{\mu_4}{\mu_2^{2}}, \quad \gamma_2 = \beta_2 - 3. \]The program computes the central moments both ways — through the conversion and directly from the deviations — and reports that they agree. That is the check worth writing into the record, because the conversion formulae are easy to mis-copy and a mis-copied \(\mu_3\) changes the sign of the skewness without changing anything that looks wrong.
Note also why \(\gamma_1\) is preferred to \(\beta_1\): squaring throws the sign away, so \(\beta_1\) says how skew the data is but not which way.
| Function or statement | What it does |
|---|---|
sum(v ** r for v in x) / n | the \(r\)th raw moment |
m1, m2, m3, m4 = m | unpack a list into four names |
max(abs(a - b) ...) < 1e-8 | the two routes agree, allowing for rounding |
... if g1 > 0 else ... | choose the message by the sign |
# Practical 6 -- the first four raw and central moments, skewness and kurtosis
import math
# Step 1: Raw moments
def raw_moments(x, k=4):
n = len(x)
return [sum(v ** r for v in x) / n for r in range(1, k + 1)]
# Step 2: Central moments from the raw moments
def central_from_raw(m):
m1, m2, m3, m4 = m
mu2 = m2 - m1 ** 2
mu3 = m3 - 3 * m1 * m2 + 2 * m1 ** 3
mu4 = m4 - 4 * m1 * m3 + 6 * m1 ** 2 * m2 - 3 * m1 ** 4
return [0.0, mu2, mu3, mu4]
# Step 3: Central moments directly, as a check
def central_direct(x):
n = len(x)
xb = sum(x) / n
return [0.0] + [sum((v - xb) ** r for v in x) / n for r in (2, 3, 4)]
# Step 4: Compute both ways and compare
x = [12, 15, 11, 18, 22, 14, 16, 19, 13, 25, 17, 20, 14, 16, 21]
m = raw_moments(x)
print("raw moments m1..m4 :", " ".join(f"{v:.4f}" for v in m))
c = central_from_raw(m)
d = central_direct(x)
print("central, from raw moments:", " ".join(f"{v:.4f}" for v in c[1:]))
print("central, computed direct :", " ".join(f"{v:.4f}" for v in d[1:]))
print("agree to within 1e-8 :", max(abs(a - b) for a, b in zip(c, d)) < 1e-8)
# Step 5: Skewness and kurtosis
mu2, mu3, mu4 = c[1], c[2], c[3]
b1 = mu3 ** 2 / mu2 ** 3
g1 = mu3 / mu2 ** 1.5
b2 = mu4 / mu2 ** 2
g2 = b2 - 3
print(f"beta1 {b1:.6f} gamma1 {g1:+.6f} beta2 {b2:.6f} gamma2 {g2:+.6f}")
print("skew :", "positive -- the long tail is to the right" if g1 > 0 else "negative")
print("kurt :", "platykurtic -- flatter than normal" if g2 < 0 else "leptokurtic")
print("sd :", f"{math.sqrt(mu2):.6f}")
Saved as p6.py and run with python3 p6.py, it printed:
raw moments m1..m4 : 16.8667 299.1333 5563.2667 108043.1333
central, from raw moments: 14.6489 23.7286 506.5591
central, computed direct : 14.6489 23.7286 506.5591
agree to within 1e-8 : True
beta1 0.179114 gamma1 +0.423219 beta2 2.360591 gamma2 -0.639409
skew : positive -- the long tail is to the right
kurt : platykurtic -- flatter than normal
sd : 3.827387
The central moments are \(\mu_2 = 14.6489\), \(\mu_3 = 23.7286\) and \(\mu_4 = 506.5591\) by both routes. \(\gamma_1 = +0.4232\), so the data is positively skewed, with the long tail to the right; \(\beta_2 = 2.3606 < 3\) (\(\gamma_2 = -0.6394\)), so it is platykurtic, flatter than the normal.
Starting from a uniform generator written out in full, generate 20,000 values from each of \(U(2,8)\), Bin\((10, 0.3)\), Poisson\((4)\), \(N(5, 2^{2})\) and Exp\((0.5)\), and compare each sample's mean and variance with the theoretical ones.
Generate samples from the uniform, binomial, Poisson, normal and exponential distributions using the algorithms, not a library.
The base generator. Everything rests on a stream of \(U(0,1)\) values, here Lehmer's minimal standard generator \(x_{k+1} = 16807x_k \bmod (2^{31}-1)\), whose output divided by the modulus is uniform on \((0,1)\). It is deterministic: the same seed gives the same stream, which is what makes the output below reproducible.
| Distribution | Algorithm | Why it works |
|---|---|---|
| \(U(a,b)\) | \(a + (b-a)U\) | inverse transform, the c.d.f. being linear |
| Bernoulli\((p)\) | \(1\) if \(U < p\) | \(P(U < p) = p\) |
| Bin\((n,p)\) | sum \(n\) Bernoullis | the definition of the binomial |
| Poisson\((\lambda)\) | multiply uniforms until the product falls below \(e^{-\lambda}\) | equivalent to counting exponential inter-arrivals inside one unit of time |
| \(N(\mu,\sigma^{2})\) | \(\mu + \sigma\sqrt{-2\ln U_1}\cos(2\pi U_2)\) | Box–Muller: the polar form of a bivariate standard normal |
| Exp\((\lambda)\) | \(-\ln(U)/\lambda\) | inverse transform on \(F(x) = 1 - e^{-\lambda x}\) |
| Function or statement | What it does |
|---|---|
class LCG: … self.x | a class whose object remembers where the stream has got to |
(16807 * self.x) % 2147483647 | the next value, modulo \(2^{31}-1\) |
while True: … return k | loop until the condition is met, then leave the function |
math.log, math.cos, math.pi | Box–Muller and the inverse transforms |
[uniform(2, 8) for _ in range(N)] | a sample of \(N\) values |
# Practical 7 -- random numbers from five distributions, built from an LCG upward
import math
# Step 1: The base generator
class LCG:
"""Lehmer's minimal standard generator: x <- 16807 x mod (2**31 - 1)."""
def __init__(self, seed=12345):
self.x = seed
def uniform(self):
self.x = (16807 * self.x) % 2147483647
return self.x / 2147483647
rng = LCG(12345)
# Step 2: Uniform, Bernoulli and binomial
def uniform(a, b): # inverse transform on U(0,1)
return a + (b - a) * rng.uniform()
def bernoulli(p):
return 1 if rng.uniform() < p else 0
def binomial(n, p): # sum of n Bernoulli trials
return sum(bernoulli(p) for _ in range(n))
# Step 3: Poisson by Knuth's method
def poisson(lam): # Knuth: multiply uniforms until below e^-lam
L, k, prod = math.exp(-lam), 0, 1.0
while True:
prod *= rng.uniform()
if prod <= L:
return k
k += 1
# Step 4: Normal by Box-Muller
def normal(mu=0.0, sigma=1.0): # Box-Muller, one of the pair kept
u1, u2 = rng.uniform(), rng.uniform()
z = math.sqrt(-2 * math.log(u1)) * math.cos(2 * math.pi * u2)
return mu + sigma * z
# Step 5: Exponential by inverse transform
def exponential(lam): # inverse transform: -ln(U)/lambda
return -math.log(rng.uniform()) / lam
# Step 6: Compare a sample with the theory
def summarise(name, sample, mean_th, var_th):
n = len(sample)
m = sum(sample) / n
v = sum((x - m) ** 2 for x in sample) / n
print(f"{name:12s} n={n} mean {m:8.4f} (theory {mean_th:7.4f})"
f" var {v:9.4f} (theory {var_th:8.4f})")
# Step 7: Draw 20,000 of each and compare
N = 20000
summarise("Uniform(2,8)", [uniform(2, 8) for _ in range(N)], 5.0, 36 / 12)
summarise("Bin(10,0.3)", [binomial(10, 0.3) for _ in range(N)], 3.0, 10 * 0.3 * 0.7)
summarise("Poisson(4)", [poisson(4) for _ in range(N)], 4.0, 4.0)
summarise("Normal(5,2)", [normal(5, 2) for _ in range(N)], 5.0, 4.0)
summarise("Exp(0.5)", [exponential(0.5) for _ in range(N)], 2.0, 4.0)
Saved as p7.py and run with python3 p7.py, it printed:
Uniform(2,8) n=20000 mean 5.0051 (theory 5.0000) var 3.0352 (theory 3.0000)
Bin(10,0.3) n=20000 mean 3.0009 (theory 3.0000) var 2.0996 (theory 2.1000)
Poisson(4) n=20000 mean 4.0315 (theory 4.0000) var 4.0750 (theory 4.0000)
Normal(5,2) n=20000 mean 5.0204 (theory 5.0000) var 4.0090 (theory 4.0000)
Exp(0.5) n=20000 mean 1.9800 (theory 2.0000) var 3.9398 (theory 4.0000)
The verification is the exercise. Generating numbers is easy; showing they come from the right distribution is the work. Each sample's mean and variance are printed beside the theoretical values, and all five agree to about two decimal places at \(n = 20{,}000\) — which is the accuracy a Monte Carlo standard error of \(\sigma/\sqrt{n}\) predicts. Here \(\sigma/\sqrt{n}\) is between 0.010 and 0.014, and the largest gap in a mean, the Poisson's 0.0315, is about two standard errors.
All five samples have means and variances close to the theory: for example Bin\((10, 0.3)\) gives mean 3.0009 and variance 2.0996 against 3 and 2.1. The algorithms generate the distributions they claim to, within sampling error.
Fit a binomial, a Poisson and a negative binomial distribution to the frequency distribution below, test each fit by \(\chi^{2}\), and say which describes the data.
| \(x\) | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| \(f\) | 447 | 132 | 42 | 21 | 3 | 2 |
Fit all three discrete distributions to one data set, test each fit, and let the test choose.
tails.py.Read the data before fitting anything. The variance-to-mean ratio is \(1.4849\). For a Poisson it should be 1; for a binomial it must be below 1, since \(npq < np\); above 1 means over-dispersion, which is the negative binomial's territory. The answer is visible before a single expected frequency is computed, and the tests below only confirm it.
| Fit | Estimates | Constraint on the variance |
|---|---|---|
| Binomial \((N, p)\) | \(N\) fixed by the largest possible count, \(\hat p = \bar x/N\) | \(\sigma^{2} < \mu\) |
| Poisson \((\lambda)\) | \(\hat\lambda = \bar x\) | \(\sigma^{2} = \mu\) |
| Negative binomial \((r, p)\) | \(\hat p = \bar x/s^{2}\), \(\hat r = \bar x^{2}/(s^{2}-\bar x)\) | \(\sigma^{2} > \mu\) |
The goodness-of-fit test. \(\chi^{2} = \sum (O_i - E_i)^{2}/E_i\) on \(k - 1 - (\text{parameters estimated})\) degrees of freedom, after pooling the upper tail until every expected frequency reaches 5. Both adjustments matter: without pooling, a class with \(E = 0.08\) contributes a huge term that is pure noise; without subtracting the estimated parameters the degrees of freedom are too many and every fit looks better than it is.
| Function or statement | What it does |
|---|---|
from tails import chi2_upper | the \(\chi^{2}\) tail area from Practical 0 |
math.factorial | the factorials in the binomial coefficient and the Poisson |
fits = {...} | a dictionary of the three lists of expected frequencies, keyed by name |
e.pop(), then e[-1] += ... | pool the last class into the one before it (pop first, then add) |
"#" * k | a bar of \(k\) characters, since no plotting package is permitted |
# Practical 8 -- fit Binomial, Poisson and Negative Binomial to ONE data set
# and let the goodness-of-fit test choose between them
import math
from tails import chi2_upper
# Step 1: Enter the data; the mean, the variance and their ratio
x = [0, 1, 2, 3, 4, 5]
f = [447, 132, 42, 21, 3, 2]
n = sum(f)
mean = sum(xi * fi for xi, fi in zip(x, f)) / n
var = sum(fi * (xi - mean) ** 2 for xi, fi in zip(x, f)) / n
print(f"n = {n} mean = {mean:.6f} variance = {var:.6f} var/mean = {var/mean:.6f}")
# Step 2: The three probability functions
def C(n, r):
return math.factorial(n) // (math.factorial(r) * math.factorial(n - r))
def binomial_pmf(N, p, k):
return C(N, k) * p ** k * (1 - p) ** (N - k)
def poisson_pmf(lam, k):
return math.exp(-lam) * lam ** k / math.factorial(k)
def nbinom_pmf(r, p, k):
# P(X = k) = Gamma(r+k)/(k! Gamma(r)) p^r (1-p)^k
coef = 1.0
for i in range(k):
coef *= (r + i) / (i + 1)
return coef * p ** r * (1 - p) ** k
# Step 3: Estimate the parameters
N = 5
p_bin = mean / N
lam = mean
p_nb = mean / var
r_nb = mean ** 2 / (var - mean)
print(f"Binomial N = {N}, p = {p_bin:.6f} (p estimated by the method of moments)")
print(f"Poisson lambda = {lam:.6f}")
print(f"NegBin r = {r_nb:.6f}, p = {p_nb:.6f}")
# Step 4: Expected frequencies
fits = {
"Binomial": [n * binomial_pmf(N, p_bin, k) for k in x],
"Poisson": [n * poisson_pmf(lam, k) for k in x],
"NegBin": [n * nbinom_pmf(r_nb, p_nb, k) for k in x],
}
# the tail beyond k = 5 belongs to the last class for the two unbounded fits
for name in ("Poisson", "NegBin"):
fits[name][-1] = n - sum(fits[name][:-1])
print()
print(" k observed Binomial Poisson NegBin")
for i, k in enumerate(x):
print(f" {k} {f[i]:8d} {fits['Binomial'][i]:8.2f} "
f"{fits['Poisson'][i]:8.2f} {fits['NegBin'][i]:8.2f}")
# Step 5: Pool the tail, then chi-square
def pooled_chi2(obs, exp, params):
"""Pool the upper tail until every expected frequency is at least 5."""
o, e = list(obs), list(exp)
while len(e) > 2 and e[-1] < 5:
last_e, last_o = e.pop(), o.pop()
e[-1] += last_e
o[-1] += last_o
chi2 = sum((oi - ei) ** 2 / ei for oi, ei in zip(o, e))
df = len(e) - 1 - params
return chi2, df, len(e)
print()
# Step 6: The test for each fit
for name, params in (("Binomial", 1), ("Poisson", 1), ("NegBin", 2)):
chi2, df, k = pooled_chi2(f, fits[name], params)
print(f"{name:9s} chi-square {chi2:9.4f} on {df} df "
f"({k} classes after pooling, {params} parameter(s) estimated)")
for name, params in (("Binomial", 1), ("Poisson", 1), ("NegBin", 2)):
chi2, df, _ = pooled_chi2(f, fits[name], params)
print(f"{name:9s} p-value {chi2_upper(chi2, df):.4g}")
# Step 7: Draw the comparison with characters
# the "curve plot", drawn with characters since no plotting package is allowed
print()
print("observed (#) against the negative binomial fit (o), one column per count")
scale = 460 / 46
for i, k in enumerate(x):
bar = "#" * max(1, round(f[i] / scale))
fit = round(fits["NegBin"][i] / scale)
print(f" {k} {bar:<48s}| fit {'o' * max(1, fit)}")
Saved as p8.py and run with python3 p8.py, it printed:
n = 647 mean = 0.465224 variance = 0.690831 var/mean = 1.484942
Binomial N = 5, p = 0.093045 (p estimated by the method of moments)
Poisson lambda = 0.465224
NegBin r = 0.959340, p = 0.673427
k observed Binomial Poisson NegBin
0 447 397.04 406.31 442.77
1 132 203.66 189.03 138.72
2 42 41.79 43.97 44.38
3 21 4.29 6.82 14.30
4 3 0.22 0.79 4.62
5 2 0.00 0.08 2.22
Binomial chi-square 41.6739 on 1 df (3 classes after pooling, 1 parameter(s) estimated)
Poisson chi-square 64.9467 on 2 df (4 classes after pooling, 1 parameter(s) estimated)
NegBin chi-square 4.1299 on 2 df (5 classes after pooling, 2 parameter(s) estimated)
Binomial p-value 1.078e-10
Poisson p-value 7.889e-15
NegBin p-value 0.1268
observed (#) against the negative binomial fit (o), one column per count
0 ############################################# | fit oooooooooooooooooooooooooooooooooooooooooooo
1 ############# | fit oooooooooooooo
2 #### | fit oooo
3 ## | fit o
4 # | fit o
5 # | fit o
The negative binomial pays two degrees of freedom and still wins comfortably. The last block draws the comparison with characters, since no plotting package is permitted — and it is enough to see that the fit tracks the observed counts.
The variance-to-mean ratio is 1.4849, so the data is over-dispersed. The binomial (\(\chi^{2} = 41.67\) on 1 df) and the Poisson (\(\chi^{2} = 64.95\) on 2 df) are both rejected, with \(p\) values below \(10^{-9}\). The negative binomial, with \(\hat r = 0.9593\) and \(\hat p = 0.6734\), gives \(\chi^{2} = 4.13\) on 2 df, \(p = 0.127\): it fits, and it is the distribution that describes these data.
Fit a normal, an exponential and a Cauchy distribution to the grouped data below, test each fit by \(\chi^{2}\), and say which describes the data.
| Class | 10–20 | 20–30 | 30–40 | 40–50 | 50–60 | 60–70 | 70–80 |
|---|---|---|---|---|---|---|---|
| \(f\) | 9 | 15 | 37 | 55 | 36 | 17 | 6 |
Fit three continuous distributions to one grouped data set and test each.
tails.py.Expected frequencies come from the c.d.f., not the density. For a class \((a, b]\),
\[ E = n\left[F(b) - F(a)\right], \]and the two outer classes are opened to \(\pm\infty\) so that the probabilities sum to exactly 1. Using \(n\,h\,f(m)\) with the density at the mid-point is an approximation that leaves the expected frequencies not quite summing to \(n\), and the resulting \(\chi^{2}\) is not the one the test assumes.
| Distribution | \(F(x)\) | Fitted by |
|---|---|---|
| Normal | \(\tfrac12\left[1 + \operatorname{erf}\!\left( \frac{x-\mu}{\sigma\sqrt2}\right)\right]\) | \(\bar x\) and \(s\) |
| Exponential | \(1 - e^{-\lambda x}\) | \(\hat\lambda = 1/\bar x\) |
| Cauchy | \(\tfrac12 + \tfrac1\pi\arctan\!\left(\frac{x-x_0}{\gamma}\right)\) | the median and half the interquartile range |
Why the Cauchy is fitted differently. It has no mean and no variance — the defining integrals diverge — so the method of moments does not merely perform badly, it does not exist. The median estimates the location \(x_0\) and half the interquartile range estimates the scale \(\gamma\), because for a Cauchy the quartiles are exactly \(x_0 \pm \gamma\).
| Function or statement | What it does |
|---|---|
math.erf | the error function, for the normal c.d.f. |
math.atan | the arctangent, for the Cauchy c.d.f. |
def expected(cdf, *args) | one function for all three fits: the c.d.f. is passed in as an argument |
from tails import chi2_upper | the \(\chi^{2}\) tail area from Practical 0 |
# Practical 9 -- fit Normal, Exponential and Cauchy to the same grouped data
import math
from tails import chi2_upper
# Step 1: Enter the grouped data; the mean and the sd
edges = [10, 20, 30, 40, 50, 60, 70, 80]
obs = [9, 15, 37, 55, 36, 17, 6]
n = sum(obs)
mids = [(edges[i] + edges[i + 1]) / 2 for i in range(len(obs))]
mean = sum(m * f for m, f in zip(mids, obs)) / n
var = sum(f * (m - mean) ** 2 for m, f in zip(mids, obs)) / n
sd = math.sqrt(var)
print(f"n = {n} mean = {mean:.6f} sd = {sd:.6f}")
# Step 2: Quartiles by interpolation, for the Cauchy
def quantile(q):
"""The q-quantile of the grouped distribution, by linear interpolation."""
target, cum = q * n, 0
for i, f in enumerate(obs):
if cum + f >= target:
return edges[i] + (target - cum) / f * (edges[i + 1] - edges[i])
cum += f
med = quantile(0.5)
q1, q3 = quantile(0.25), quantile(0.75)
gamma = (q3 - q1) / 2
print(f"median = {med:.6f} Q1 = {q1:.6f} Q3 = {q3:.6f} half-IQR = {gamma:.6f}")
# Step 3: The three distribution functions
def normal_cdf(x, mu, s):
return 0.5 * (1 + math.erf((x - mu) / (s * math.sqrt(2))))
def expon_cdf(x, lam):
return 0.0 if x <= 0 else 1 - math.exp(-lam * x)
def cauchy_cdf(x, x0, g):
return 0.5 + math.atan((x - x0) / g) / math.pi
# Step 4: The estimates
lam = 1 / mean
print(f"Normal mu = {mean:.6f}, sigma = {sd:.6f} (moment estimates)")
print(f"Exponent lambda = 1/mean = {lam:.6f}")
print(f"Cauchy x0 = median = {med:.6f}, gamma = half-IQR = {gamma:.6f}")
print(" -- the Cauchy has no mean or variance, so it cannot be fitted by moments;")
print(" the median and the half-interquartile range are the natural substitutes.")
# Step 5: Expected frequencies from the distribution function
def expected(cdf, *args):
"""Expected frequencies, with the two outer classes opened to +-infinity
so that the probabilities sum to exactly 1."""
e = []
for i in range(len(obs)):
lo = 0.0 if i == 0 else cdf(edges[i], *args)
hi = 1.0 if i == len(obs) - 1 else cdf(edges[i + 1], *args)
e.append(n * (hi - lo))
return e
fits = {
"Normal": expected(normal_cdf, mean, sd),
"Exponential": expected(expon_cdf, lam),
"Cauchy": expected(cauchy_cdf, med, gamma),
}
print()
print(" class observed Normal Exponential Cauchy")
for i in range(len(obs)):
print(f" {edges[i]:2d} - {edges[i+1]:2d} {obs[i]:8d} {fits['Normal'][i]:9.2f} "
f"{fits['Exponential'][i]:11.2f} {fits['Cauchy'][i]:8.2f}")
print()
# Step 6: Chi-square for each fit
for name, params in (("Normal", 2), ("Exponential", 1), ("Cauchy", 2)):
e = fits[name]
chi2 = sum((o - ei) ** 2 / ei for o, ei in zip(obs, e))
df = len(obs) - 1 - params
print(f"{name:12s} chi-square {chi2:10.4f} on {df} df p = {chi2_upper(chi2, df):.4g}")
Saved as p9.py and run with python3 p9.py, it printed:
n = 175 mean = 44.657143 sd = 13.852164
median = 44.818182 Q1 = 35.337838 Q3 = 54.236111 half-IQR = 9.449137
Normal mu = 44.657143, sigma = 13.852164 (moment estimates)
Exponent lambda = 1/mean = 0.022393
Cauchy x0 = median = 44.818182, gamma = half-IQR = 9.449137
-- the Cauchy has no mean or variance, so it cannot be fitted by moments;
the median and the half-interquartile range are the natural substitutes.
class observed Normal Exponential Cauchy
10 - 20 9 6.57 63.18 20.26
20 - 30 15 18.81 22.44 11.36
30 - 40 37 39.09 17.93 29.61
40 - 50 55 49.31 14.34 54.21
50 - 60 36 37.77 11.46 28.55
60 - 70 17 17.56 9.16 11.02
70 - 80 6 5.89 36.50 20.00
Normal chi-square 2.5410 on 4 df p = 0.6373
Exponential chi-square 269.2814 on 5 df p = 3.992e-56
Cauchy chi-square 24.2799 on 4 df p = 7.019e-05
The output rejects the exponential outright, rejects the Cauchy at \(p = 0.00007\) because its tails are far too heavy for this data, and accepts the normal at \(p = 0.637\).
chi2_upper; the true value is
\(3.99\times10^{-56}\) (see the note in Practical 0). The decision is the same.
The normal with \(\mu = 44.66\) and \(\sigma = 13.85\) fits: \(\chi^{2} = 2.54\) on 4 df, \(p = 0.637\). The exponential (\(\chi^{2} = 269.28\) on 5 df) and the Cauchy (\(\chi^{2} = 24.28\) on 4 df, \(p = 0.00007\)) are rejected. The data is described by the normal distribution.
For the ten pairs below, find the correlation coefficient and both regression lines, verify that \(b_{yx}b_{xy} = r^{2}\) and that both lines pass through \((\bar x, \bar y)\), find the angle between the lines, and predict \(y\) at \(x = 69\).
| \(x\) | 65 | 63 | 67 | 64 | 68 | 62 | 70 | 66 | 68 | 67 |
|---|---|---|---|---|---|---|---|---|---|---|
| \(y\) | 68 | 66 | 68 | 65 | 69 | 66 | 68 | 65 | 71 | 67 |
Compute the correlation coefficient and both regression lines, and verify the three relations between them.
and the three facts the program checks:
| Function or statement | What it does |
|---|---|
zip(x, y) | pair each \(x\) with its \(y\) |
math.sqrt | the square roots in \(r\) and the residual sd |
math.atan, math.degrees | the angle between the lines, in degrees |
# Practical 10 -- correlation and BOTH regression lines
import math
# Step 1: Enter the data; the sums of squares and products
x = [65, 63, 67, 64, 68, 62, 70, 66, 68, 67]
y = [68, 66, 68, 65, 69, 66, 68, 65, 71, 67]
n = len(x)
xb, yb = sum(x) / n, sum(y) / n
Sxx = sum((v - xb) ** 2 for v in x)
Syy = sum((v - yb) ** 2 for v in y)
Sxy = sum((u - xb) * (v - yb) for u, v in zip(x, y))
print(f"n = {n} xbar = {xb:.4f} ybar = {yb:.4f}")
print(f"Sxx = {Sxx:.4f} Syy = {Syy:.4f} Sxy = {Sxy:.4f}")
# Step 2: The correlation
r = Sxy / math.sqrt(Sxx * Syy)
print(f"r = {r:.6f} r^2 = {r*r:.6f}")
# Step 3: Both regression lines, and the checks
b_yx = Sxy / Sxx
a_yx = yb - b_yx * xb
b_xy = Sxy / Syy
a_xy = xb - b_xy * yb
print(f"y on x : y = {a_yx:.6f} + {b_yx:.6f} x")
print(f"x on y : x = {a_xy:.6f} + {b_xy:.6f} y")
print(f"check : b_yx * b_xy = {b_yx*b_xy:.6f} and r^2 = {r*r:.6f}")
print(f"the two lines meet at the point (xbar, ybar) = ({xb:.4f}, {yb:.4f})")
print(f" y on x at xbar : {a_yx + b_yx*xb:.4f}")
print(f" x on y at ybar : {a_xy + b_xy*yb:.4f}")
# Step 4: The angle between the lines
# the angle between the two lines, which is zero only when |r| = 1
m1, m2 = b_yx, 1 / b_xy
theta = math.degrees(math.atan(abs((m2 - m1) / (1 + m1 * m2))))
print(f"angle between the lines = {theta:.4f} degrees "
f"(it would be 0 if r were +-1, and 90 if r were 0)")
# Step 5: The residual sd and a prediction
# residual standard deviation and the prediction at a new x
see = math.sqrt((Syy - b_yx * Sxy) / (n - 2))
print(f"residual sd (n-2 divisor) = {see:.6f}")
print(f"predicted y at x = 69 : {a_yx + b_yx*69:.6f}")
Saved as p10.py and run with python3 p10.py, it printed:
n = 10 xbar = 66.0000 ybar = 67.3000
Sxx = 56.0000 Syy = 32.1000 Sxy = 27.0000
r = 0.636821 r^2 = 0.405541
y on x : y = 35.478571 + 0.482143 x
x on y : x = 9.392523 + 0.841121 y
check : b_yx * b_xy = 0.405541 and r^2 = 0.405541
the two lines meet at the point (xbar, ybar) = (66.0000, 67.3000)
y on x at xbar : 67.3000
x on y at ybar : 66.0000
angle between the lines = 24.1914 degrees (it would be 0 if r were +-1, and 90 if r were 0)
residual sd (n-2 divisor) = 1.544431
predicted y at x = 69 : 68.746429
\(b_{yx}b_{xy}\) and \(r^{2}\) both come out to \(0.405541\), and the angle between the lines is \(24.19^{\circ}\).
The two lines are not the same line, and neither is "the" line. \(y\) on \(x\) minimises vertical deviations and is the one to use for predicting \(y\); \(x\) on \(y\) minimises horizontal deviations. Predicting \(x\) from the first by rearranging it is a standard and serious error.
\(r = 0.6368\), a moderate positive correlation. The line of \(y\) on \(x\) is \(y = 35.4786 + 0.4821x\) and of \(x\) on \(y\) is \(x = 9.3925 + 0.8411y\); they meet at \((66, 67.3)\) at an angle of \(24.19^{\circ}\). The predicted \(y\) at \(x = 69\) is 68.75.
Carry out, at the 5% level:
Carry out seven tests, with every tail area computed rather than read from a table.
| Test | Statistic | Null distribution |
|---|---|---|
| One mean | \(\dfrac{\bar x - \mu_0}{s/\sqrt n}\) | \(t_{n-1}\) |
| Two means, independent | \(\dfrac{\bar x_1 - \bar x_2}{\sqrt{s_p^{2}\left(\frac{1}{n_1}+\frac{1}{n_2}\right)}}\) | \(t_{n_1+n_2-2}\) |
| Two means, paired | \(\dfrac{\bar d}{s_d/\sqrt n}\) | \(t_{n-1}\) |
| One variance | \((n-1)s^{2}/\sigma_0^{2}\) | \(\chi^{2}_{n-1}\) |
| Two variances | \(s_1^{2}/s_2^{2}\), larger over smaller | \(F_{n_1-1,\,n_2-1}\) |
| \(\rho = 0\) | \(r\sqrt{\dfrac{n-2}{1-r^{2}}}\) | \(t_{n-2}\) |
| \(\rho = \rho_0 \ne 0\) | \(\left(z - z_0\right)\sqrt{n-3}\), \(z = \tfrac12\ln\frac{1+r}{1-r}\) | \(N(0,1)\) |
| Function or statement | What it does |
|---|---|
from tails import t_two_sided, F_upper, chi2_upper, normal_two_sided | the four tail areas from Practical 0 |
[b - a for b, a in zip(before, after)] | the paired differences |
max(s2a, s2b) / min(s2a, s2b) | the larger variance over the smaller |
2 * min(p_lo, p_hi) | a two-sided \(p\) value from the two tails of \(\chi^{2}\) |
math.log | Fisher's \(z\) transformation |
# Practical 11 -- tests for means, variances and correlations,
# with every tail area taken from tails.py rather than from a table
import math
from tails import t_two_sided, F_upper, chi2_upper, normal_two_sided
# Step 1: Size, mean and variance (n - 1 divisor)
def stats(a):
n = len(a)
m = sum(a) / n
s2 = sum((v - m) ** 2 for v in a) / (n - 1)
return n, m, s2
# Step 2: One-sample t
sample = [48, 52, 55, 49, 53, 51, 47, 54, 50, 52, 56, 50]
n1, m1, s21 = stats(sample)
mu0 = 50
t1 = (m1 - mu0) / math.sqrt(s21 / n1)
print(f"one-sample t : n {n1} mean {m1:.6f} s^2 {s21:.6f}")
print(f" H0: mu = {mu0} t = {t1:.6f} on {n1-1} df p = {t_two_sided(t1, n1-1):.6f}")
# Step 3: Two independent samples, pooled variance
A = [23, 27, 25, 29, 24, 26, 28]
B = [31, 28, 33, 30, 35, 29]
na, ma, s2a = stats(A)
nb, mb, s2b = stats(B)
sp2 = ((na - 1) * s2a + (nb - 1) * s2b) / (na + nb - 2)
t2 = (ma - mb) / math.sqrt(sp2 * (1 / na + 1 / nb))
print(f"two-sample t : means {ma:.6f} and {mb:.6f} pooled s^2 {sp2:.6f}")
print(f" t = {t2:.6f} on {na+nb-2} df p = {t_two_sided(t2, na+nb-2):.6f}")
# Step 4: Paired t
before = [72, 68, 75, 70, 66, 74, 69, 71]
after = [70, 65, 71, 68, 65, 70, 67, 69]
d = [b - a for b, a in zip(before, after)]
nd, md, s2d = stats(d)
t3 = md / math.sqrt(s2d / nd)
print(f"paired t : differences {d}")
print(f" mean d {md:.6f} t = {t3:.6f} on {nd-1} df p = {t_two_sided(t3, nd-1):.8f}")
# Step 5: One variance against a stated value
sigma0_sq = 6.0
chi = (n1 - 1) * s21 / sigma0_sq
p_lo, p_hi = 1 - chi2_upper(chi, n1 - 1), chi2_upper(chi, n1 - 1)
print(f"one variance : H0 sigma^2 = {sigma0_sq} chi-square = {chi:.6f} on {n1-1} df")
print(f" two-sided p = {2*min(p_lo, p_hi):.6f}")
# Step 6: Two variances
F = max(s2a, s2b) / min(s2a, s2b)
df1, df2 = (na - 1, nb - 1) if s2a > s2b else (nb - 1, na - 1)
print(f"two variances: s^2 {s2a:.6f} and {s2b:.6f} F = {F:.6f} on ({df1}, {df2}) df")
print(f" two-sided p = {2*F_upper(F, df1, df2):.6f} -- pooling was justified")
# Step 7: A correlation against zero
x = [65, 63, 67, 64, 68, 62, 70, 66, 68, 67]
y = [68, 66, 68, 65, 69, 66, 68, 65, 71, 67]
nx = len(x)
xb, yb = sum(x) / nx, sum(y) / nx
Sxx = sum((v - xb) ** 2 for v in x)
Syy = sum((v - yb) ** 2 for v in y)
Sxy = sum((u - xb) * (v - yb) for u, v in zip(x, y))
r = Sxy / math.sqrt(Sxx * Syy)
tr = r * math.sqrt((nx - 2) / (1 - r * r))
print(f"correlation : r = {r:.6f} t = {tr:.6f} on {nx-2} df "
f"p = {t_two_sided(tr, nx-2):.6f}")
# Step 8: A correlation against a stated non-zero value, by Fisher's z
rho0 = 0.8
z = 0.5 * math.log((1 + r) / (1 - r))
z0 = 0.5 * math.log((1 + rho0) / (1 - rho0))
zstat = (z - z0) * math.sqrt(nx - 3)
print(f" Fisher z = {z:.6f}, z0 = {z0:.6f} test statistic {zstat:.6f} "
f"p = {normal_two_sided(zstat):.6f}")
Saved as p11.py and run with python3 p11.py, it printed:
one-sample t : n 12 mean 51.416667 s^2 7.719697
H0: mu = 50 t = 1.766274 on 11 df p = 0.105050
two-sample t : means 26.000000 and 31.000000 pooled s^2 5.636364
t = -3.785502 on 11 df p = 0.003018
paired t : differences [2, 3, 4, 2, 1, 4, 2, 2]
mean d 2.500000 t = 6.614378 on 7 df p = 0.00030027
one variance : H0 sigma^2 = 6.0 chi-square = 14.152778 on 11 df
two-sided p = 0.449309
two variances: s^2 4.666667 and 6.800000 F = 1.457143 on (5, 6) df
two-sided p = 0.654305 -- pooling was justified
correlation : r = 0.636821 t = 2.336152 on 8 df p = 0.047701
Fisher z = 0.752807, z0 = 1.098612 test statistic -0.914914 p = 0.360237
Three points that decide marks.
(a) Three treatments gave the yields A: 20, 22, 19, 24, 25; B: 27, 25, 30, 28, 26; C: 23, 21, 24, 22, 25. Test whether the treatment means differ.
(b) Three varieties were grown in four blocks, one plot each:
| Block 1 | Block 2 | Block 3 | Block 4 | |
|---|---|---|---|---|
| Variety 1 | 18 | 22 | 20 | 16 |
| Variety 2 | 23 | 25 | 26 | 21 |
| Variety 3 | 15 | 19 | 18 | 14 |
Test whether the varieties differ and whether the blocks differ.
Produce both analysis of variance tables from the correction factor upward.
tails.py.and for the two-way layout with one observation per cell,
\[ SS_{\text{rows}} = \frac{\sum_i R_i^{2}}{c} - CF, \qquad SS_{\text{columns}} = \frac{\sum_j C_j^{2}}{r} - CF, \] \[ SS_{\text{error}} = SS_{\text{total}} - SS_{\text{rows}} - SS_{\text{columns}} \quad\text{on } (r-1)(c-1) \text{ degrees of freedom.} \]Error is obtained by subtraction, always. Computing it directly is possible but slower and gives no check; obtaining it by subtraction and then confirming that the components add back to the total — which the program prints — catches an arithmetic slip anywhere in the table.
| Function or statement | What it does |
|---|---|
from tails import F_upper | the \(F\) tail area from Practical 0 |
groups = {"A": [...], ...} | the treatments, as a dictionary of lists |
[v for g in groups.values() for v in g] | all the observations in one list |
sum(tab[i][j] for i in range(r)) | a column total |
f"{name:<14}{ss:12.4f}" | line the table up in columns |
# Practical 12 -- one-way and two-way analysis of variance, built from the
# correction factor upward, with the F tail area taken from tails.py
import math
from tails import F_upper
# Step 1: Print an analysis of variance table
def anova_table(rows, source_names):
print(f" {'Source':<14}{'SS':>12}{'df':>5}{'MS':>12}{'F':>10}{'p':>12}")
err_ss, err_df = rows[-1][1], rows[-1][2]
err_ms = err_ss / err_df
for name, ss, df in rows[:-1]:
ms = ss / df
F = ms / err_ms
print(f" {name:<14}{ss:12.4f}{df:5d}{ms:12.4f}{F:10.4f}{F_upper(F, df, err_df):>12.4g}")
print(f" {'Error':<14}{err_ss:12.4f}{err_df:5d}{err_ms:12.4f}")
tot_ss = sum(r[1] for r in rows)
tot_df = sum(r[2] for r in rows)
print(f" {'Total':<14}{tot_ss:12.4f}{tot_df:5d}")
# Step 2: One-way: correction factor, then the sums of squares
groups = {"A": [20, 22, 19, 24, 25],
"B": [27, 25, 30, 28, 26],
"C": [23, 21, 24, 22, 25]}
allv = [v for g in groups.values() for v in g]
N = len(allv)
G = sum(allv)
CF = G * G / N
SST = sum(v * v for v in allv) - CF
SSTr = sum(sum(g) ** 2 / len(g) for g in groups.values()) - CF
SSE = SST - SSTr
print(f"one-way: N = {N} G = {G} CF = {CF:.4f}")
anova_table([("Treatments", SSTr, len(groups) - 1),
(None, SSE, N - len(groups))], None)
# Step 3: Two-way, one observation per cell
# rows = varieties, columns = blocks
tab = [[18, 22, 20, 16],
[23, 25, 26, 21],
[15, 19, 18, 14]]
r, c = len(tab), len(tab[0])
vals = [v for row in tab for v in row]
N2 = r * c
G2 = sum(vals)
CF2 = G2 * G2 / N2
SST2 = sum(v * v for v in vals) - CF2
SSR = sum(sum(row) ** 2 for row in tab) / c - CF2
SSC = sum(sum(tab[i][j] for i in range(r)) ** 2 for j in range(c)) / r - CF2
SSE2 = SST2 - SSR - SSC
print()
print(f"two-way: N = {N2} G = {G2} CF = {CF2:.4f}")
anova_table([("Varieties", SSR, r - 1),
("Blocks", SSC, c - 1),
(None, SSE2, (r - 1) * (c - 1))], None)
# Step 4: Check that the parts add back to the total
print(f" check: SSR + SSC + SSE = {SSR + SSC + SSE2:.4f} and SST = {SST2:.4f}")
Saved as p12.py and run with python3 p12.py, it printed:
one-way: N = 15 G = 361 CF = 8688.0667
Source SS df MS F p
Treatments 76.1333 2 38.0667 8.9921 0.004109
Error 50.8000 12 4.2333
Total 126.9333 14
two-way: N = 12 G = 237 CF = 4680.7500
Source SS df MS F p
Varieties 108.5000 2 54.2500 114.8824 1.648e-05
Blocks 48.9167 3 16.3056 34.5294 0.0003516
Error 2.8333 6 0.4722
Total 160.2500 11
check: SSR + SSC + SSE = 160.2500 and SST = 160.2500
What the two-way layout can and cannot do. With one observation per cell there is nothing left over to estimate an interaction, so the model \(y_{ij} = \mu + \alpha_i + \beta_j + e_{ij}\) is an assumption, not a finding. The moment a cell holds two observations, a pure error term appears and the interaction becomes testable — which is where Design and Analysis of Experiments, Unit 1 picks the subject up.
(a) \(F = 8.99\) on (2, 12) df, \(p = 0.004\): the treatment means differ. (b) Varieties \(F = 114.88\) on (2, 6) df, \(p = 0.00002\), and blocks \(F = 34.53\) on (3, 6) df, \(p = 0.0004\): both differ. The parts add back to the total, 160.25.
statistics.mean or numpy.linalg.inv answers a different
question from the one asked.e[-2] += e.pop() does not do what it reads as: the index \(-2\) is resolved
after the pop, so the value lands in the wrong cell. Pop first, then add.