Skip to the content

Topics Covered

Matrices Sorting & Searching Frequency Tables Moments Random Generation Distribution Fitting Goodness of Fit Correlation Regression Tests of Hypotheses ANOVA
On this page
  1. The Twelve Programs
  2. Practical 0: The Tail Areas, Written Once
  3. Practical 1: Sum and Product of Two Matrices
  4. Practical 2: Determinant and Inverse of a Matrix
  5. Practical 3: Four Sorts and Two Searches
  6. Practical 4: Median and Mode of an Array
  7. Practical 5: Frequency Table, and the Five Summaries From It
  8. Practical 6: Four Moments, Skewness and Kurtosis
  9. Practical 7: Random Numbers from Five Distributions
  10. Practical 8: Fitting Binomial, Poisson and Negative Binomial
  11. Practical 9: Fitting Normal, Exponential and Cauchy
  12. Practical 10: Correlation and Both Regression Lines
  13. Practical 11: Testing Means, Variances and Correlations
  14. Practical 12: One-Way and Two-Way Analysis of Variance
  15. How Marks Are Lost
  16. What the Practical Record Should Contain
About this course. STS-105 is a practical. Its own note is the constraint that shapes every program below: “Must able to write Programs with all possibilities like usage of functions, Loops, OOP concepts, methods, built in functions etc. wherever it is possible. Without usage of Python Packages for statistical tools.” So nothing here imports 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.
The Python itself is not taught here. Variables, input and output, the decision and repetition structures, functions and arguments, modules, exception handling, lists, tuples, dictionaries, sets, strings, classes and file input and output are all covered, with worked programs, in Python Programming and Data Structures — Unit 1 for the language basics and control flow, Unit 2 for functions and modules, Unit 3 for lists, tuples, dictionaries and sets, Unit 4 for classes and exception handling, and Unit 5 for files. None of it is repeated below. What is written out here is the statistics: which formula, why that one, and what the output means.

The Twelve Programs

WHAT EACH ONE IS FOR
#ProgramThe statistical point
0The tail areas (tails.py) \(t\), \(F\), \(\chi^{2}\) and normal \(p\) values with no table, for programs 8, 9, 11 and 12
1Sum and product of two matrices shape conditions; multiplication does not commute
2Determinant and inverse cofactor expansion; the transpose inside the adjoint; detecting singularity
3Four sorts, two searches comparison counts, and why binary search needs sorted input
4Median and mode the even-\(n\) case; several modes; no mode at all
5Frequency table and five summaries the grouped formulae, and the grouping error they carry
6Four moments, skewness, kurtosis raw to central conversion, checked a second way
7Random numbers from five distributions inverse transform, Box–Muller, Knuth's Poisson — and verifying them
8Binomial, Poisson, negative binomial fits over-dispersion; pooling classes; degrees of freedom after estimation
9Normal, exponential, Cauchy fits expected frequencies from the c.d.f.; fitting a distribution with no moments
10Correlation and both regression lines \(b_{yx}b_{xy} = r^{2}\); why there are two lines
11Tests for means, variances, correlations seven tests, the tail areas computed, and the order to run them in
12One-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.

Practical 0: The Tail Areas, Written Once

1. Question

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\).

2. Aim

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.

3. Steps

  1. The continued fraction for the incomplete beta. Evaluate the continued fraction for the incomplete beta function by Lentz's method, stopping when a term changes the value by less than \(3\times10^{-16}\).
  2. The regularised incomplete beta. Multiply it by the front factor \(x^{a}(1-x)^{b}/\{a\,B(a,b)\}\), using \(I_x(a,b) = 1 - I_{1-x}(b,a)\) when \(x\) is past \((a+1)/(a+b+2)\), where the fraction converges fastest.
  3. The t and F tails from it. Both are one incomplete beta, by the two identities in the method box.
  4. The chi-square tail from the incomplete gamma. The \(\chi^{2}\) tail is the upper incomplete gamma function at \(\nu/2\) and \(q/2\): by the series for the lower part when \(q/2 < \nu/2 + 1\), and by the continued fraction for the upper part when \(q/2\) is larger, so that a very small tail is computed directly.
  5. The normal tail. \(P(|Z| > z) = \operatorname{erfc}\!\left(|z|/\sqrt2\right)\), from the standard library.
  6. Check all four against the tables. Print each function at a published 5% point. Every line must show 0.0500.
THE METHOD

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.

PYTHON USED
Function or statementWhat it does
math.lgamma(x)\(\ln\Gamma(x)\), for the front factors without overflow
math.exp, math.logthe front factors, built on the log scale
math.erfc(x)the complementary error function, \(1 - \operatorname{erf}(x)\)
for m in range(1, 300): ... breakiterate 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

4. Programme

PRACTICAL 0 — tails.py
# 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)")

5. Execution and Results

Saved as tails.py and run with python3 tails.py, it printed:

OUTPUT
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)
Corrected. The first version of 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.
RESULT

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.

Practical 1: Sum and Product of Two Matrices

1. Question

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\).

2. Aim

Add and multiply two matrices, and show that matrix multiplication does not commute.

3. Steps

  1. Check the shapes. A matrix is stored as a list of rows: the rows are len(M), the columns len(M[0]).
  2. Add elementwise. Refuse unless the two shapes agree, then add entry by entry.
  3. Multiply by inner products. Refuse unless the columns of \(A\) equal the rows of \(B\); each entry of the product is the inner product of a row of \(A\) with a column of \(B\).
  4. Print a matrix. Print each row with its entries in fixed-width columns.
  5. Enter the two matrices. Store \(A\) and \(B\) as lists of rows.
  6. Form A + B, AB and BA, and compare AB with BA. Print \(A + B\), \(AB\) and \(BA\), and compare the two products.
THE METHOD

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.

PYTHON USED
Function or statementWhat 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 liststrue only if every entry agrees

4. Programme

PRACTICAL 1 — p1.py
# 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))

5. Execution and Results

Saved as p1.py and run with python3 p1.py, it printed:

OUTPUT
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
RESULT

\(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.

Practical 2: Determinant and Inverse of a Matrix

1. Question

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.

2. Aim

Compute a determinant by cofactor expansion and an inverse by the adjoint, in exact arithmetic, and detect a singular matrix.

3. Steps

  1. Delete a row and a column. The minor \(M_{ij}\) is the matrix with row \(i\) and column \(j\) deleted.
  2. Expand the determinant along row 1. Expand along the first row, recursively, down to the \(2\times2\) case.
  3. Invert by the transposed cofactors. Refuse if \(\det = 0\). Otherwise form the cofactors, transpose them to get the adjoint, and divide by the determinant as a Fraction.
  4. Print a matrix of fractions. Print each entry right-aligned, as a fraction.
  5. Find det(A) and the inverse. Print \(\det(A)\) and \(A^{-1}\).
  6. Check that A times its inverse is I. Multiply \(A\) by \(A^{-1}\); the product must be the identity exactly.
  7. Try a singular matrix. Find \(\det(S)\), which is 0.
THE METHOD

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.

PYTHON USED
Function or statementWhat it does
from fractions import Fractionexact 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

4. Programme

PRACTICAL 2 — p2.py
# 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")

5. Execution and Results

Saved as p2.py and run with python3 p2.py, it printed:

OUTPUT
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
RESULT

\(\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.

Practical 3: Four Sorts and Two Searches

1. Question

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.

2. Aim

Implement bubble, insertion, merge and quick sort, and linear and binary search, and count the comparisons each one uses.

3. Steps

  1. Bubble sort, counting comparisons. Swap adjacent out-of-order pairs, pass after pass; stop early if a pass makes no swap.
  2. Insertion sort, counting comparisons. Grow a sorted prefix, sliding each new key back to its place.
  3. Merge sort. Split the list in two, sort each half, and merge the two sorted halves.
  4. Quick sort. Partition about the middle element into smaller, equal and larger, and sort each side.
  5. Linear and binary search, counting comparisons. Linear search scans from the start; binary search halves the interval each time, and returns the position and the comparisons used.
  6. Sort the data four ways and compare. Run all four sorts on the same data and check that they agree.
  7. Search the sorted list. Search the sorted list for a value that is there (66) and one that is not (50).
THE METHOD
MethodIdeaComparisons
Bubblerepeatedly swap adjacent out-of-order pairs \(O(n^{2})\); \(O(n)\) if already sorted, using the early exit
Insertiongrow a sorted prefix, sliding each new key back into it \(O(n^{2})\) worst, \(O(n)\) best — usually beats bubble
Mergesplit, sort each half, merge \(O(n\log n)\) always; needs extra space
Quickpartition about a pivot, recurse on each side \(O(n\log n)\) expected, \(O(n^{2})\) on a bad pivot
Linear searchscan\(O(n)\); works on unsorted data
Binary searchhalve the interval \(O(\log n)\); requires sorted data
PYTHON USED
Function or statementWhat 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, compsreturn two values, as a tuple
(lo + hi) // 2the middle position, by integer division
enumerate(a)each position together with its value

4. Programme

PRACTICAL 3 — p3.py
# 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))

5. Execution and Results

Saved as p3.py and run with python3 p3.py, it printed:

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

RESULT

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.

Practical 4: Median and Mode of an Array

1. Question

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.

2. Aim

Compute the median and the mode from first principles, and handle the two cases a library routine hides.

3. Steps

  1. Median: sort, then take the middle. Sort; take the middle value if \(n\) is odd, and the mean of the two middle values if \(n\) is even.
  2. Mode: count, then keep every value at the top count. Count each value in a dictionary in one pass, find the largest count, and keep every value that reaches it.
  3. Enter the test arrays. Enter an odd-length array, an even-length one, a bimodal one and one with no repeats.
  4. Medians for odd and even n. Print both medians.
  5. Modes: one, two and none. Print the modes, and say what each result means.
THE METHOD

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:

PYTHON USED
Function or statementWhat it does
sorted(a)a sorted copy of the list
n // 2, n % 2the middle position, and whether \(n\) is odd
freq.get(v, 0) + 1add one to a value's count, starting from 0
max(freq.values())the largest count

4. Programme

PRACTICAL 4 — p4.py
# 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'")

5. Execution and Results

Saved as p4.py and run with python3 p4.py, it printed:

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

RESULT

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.

Practical 5: Frequency Table, and the Five Summaries From It

1. Question

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

2. Aim

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.

3. Steps

  1. Enter the raw data. Store the forty values as a list.
  2. Count the classes. Count the values into five classes of width 10 from 10; a value on the top edge goes in the last class.
  3. Grouped mean. Mid-points, then \(\sum f_im_i/n\).
  4. Grouped median. Find the class that holds the \(n/2\)th value, and interpolate within it.
  5. Grouped mode. The modal class and its two neighbours, in the mode formula.
  6. Print the table and the grouped summaries. Print the table, then the five grouped summaries.
  7. The same summaries from the raw values. Compute the mean, median and variance from the raw values, and the grouping error in the mean.
  8. The tied modal class. The two middle classes tie at 12, so apply the formula to the other of them too.
THE METHOD

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.

PYTHON USED
Function or statementWhat it does
int((v - low) // width)the class a value falls in
[0] * ka 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.sqrtthe standard deviation from the variance

4. Programme

PRACTICAL 5 — p5.py
# 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")

5. Execution and Results

Saved as p5.py and run with python3 p5.py, it printed:

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

RESULT

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.

Practical 6: Four Moments, Skewness and Kurtosis

1. Question

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\).

2. Aim

Compute the first four raw moments, convert them to central moments, and from those the two shape coefficients.

3. Steps

  1. Raw moments. \(m_r' = \frac{1}{n}\sum x_i^{r}\) for \(r = 1, \dots, 4\).
  2. Central moments from the raw moments. Convert by the three formulae in the method box.
  3. Central moments directly, as a check. Compute the same central moments from the deviations about the mean.
  4. Compute both ways and compare. Print both sets and confirm that they agree.
  5. Skewness and kurtosis. Compute the four coefficients and say what the signs mean.
THE METHOD

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.

PYTHON USED
Function or statementWhat it does
sum(v ** r for v in x) / nthe \(r\)th raw moment
m1, m2, m3, m4 = munpack a list into four names
max(abs(a - b) ...) < 1e-8the two routes agree, allowing for rounding
... if g1 > 0 else ...choose the message by the sign

4. Programme

PRACTICAL 6 — p6.py
# 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}")

5. Execution and Results

Saved as p6.py and run with python3 p6.py, it printed:

OUTPUT
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
RESULT

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.

Practical 7: Random Numbers from Five Distributions

1. Question

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.

2. Aim

Generate samples from the uniform, binomial, Poisson, normal and exponential distributions using the algorithms, not a library.

3. Steps

  1. The base generator. Lehmer's generator \(x_{k+1} = 16807x_k \bmod (2^{31}-1)\), divided by the modulus, as a class that keeps its own state.
  2. Uniform, Bernoulli and binomial. \(a + (b-a)U\); a Bernoulli is \(1\) if \(U < p\); a binomial is the sum of \(n\) Bernoullis.
  3. Poisson by Knuth's method. Multiply uniforms until the product falls to \(e^{-\lambda}\) or below; the number of factors before that is the value.
  4. Normal by Box-Muller. \(\mu + \sigma\sqrt{-2\ln U_1}\cos(2\pi U_2)\), keeping one of the pair.
  5. Exponential by inverse transform. \(-\ln(U)/\lambda\).
  6. Compare a sample with the theory. Print the sample mean and variance beside the theoretical ones.
  7. Draw 20,000 of each and compare. Draw 20,000 values from each distribution and compare.
THE METHOD

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.

DistributionAlgorithmWhy 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\) Bernoullisthe 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}\)
PYTHON USED
Function or statementWhat it does
class LCG: … self.xa class whose object remembers where the stream has got to
(16807 * self.x) % 2147483647the next value, modulo \(2^{31}-1\)
while True: … return kloop until the condition is met, then leave the function
math.log, math.cos, math.piBox–Muller and the inverse transforms
[uniform(2, 8) for _ in range(N)]a sample of \(N\) values

4. Programme

PRACTICAL 7 — p7.py
# 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)

5. Execution and Results

Saved as p7.py and run with python3 p7.py, it printed:

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

RESULT

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.

Practical 8: Fitting Binomial, Poisson and Negative Binomial

1. Question

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\)012345
\(f\)447132422132

2. Aim

Fit all three discrete distributions to one data set, test each fit, and let the test choose.

3. Steps

  1. Enter the data; the mean, the variance and their ratio. Store the counts and frequencies; print \(n\), the mean, the variance and the variance-to-mean ratio.
  2. The three probability functions. The three probability functions, the binomial coefficient written out.
  3. Estimate the parameters. Estimate each distribution's parameters by the method of moments.
  4. Expected frequencies. Multiply each probability by \(n\); for the two unbounded fits the last class takes the whole upper tail.
  5. Pool the tail, then chi-square. Pool the upper classes until every expected frequency is at least 5, then compute \(\chi^{2}\) and its degrees of freedom.
  6. The test for each fit. Print each \(\chi^{2}\), its degrees of freedom and its \(p\) value from tails.py.
  7. Draw the comparison with characters. Draw the observed frequencies against the negative binomial fit in characters.
THE METHOD

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.

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

PYTHON USED
Function or statementWhat it does
from tails import chi2_upperthe \(\chi^{2}\) tail area from Practical 0
math.factorialthe 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)
"#" * ka bar of \(k\) characters, since no plotting package is permitted

4. Programme

PRACTICAL 8 — p8.py
# 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)}")

5. Execution and Results

Saved as p8.py and run with python3 p8.py, it printed:

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

RESULT

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.

Practical 9: Fitting Normal, Exponential and Cauchy

1. Question

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.

Class10–2020–3030–4040–5050–6060–7070–80
\(f\)915375536176

2. Aim

Fit three continuous distributions to one grouped data set and test each.

3. Steps

  1. Enter the grouped data; the mean and the sd. Store the class edges and frequencies; compute the mean and standard deviation from the mid-points.
  2. Quartiles by interpolation, for the Cauchy. The median and the quartiles by interpolation in the grouped distribution, and half the interquartile range.
  3. The three distribution functions. The normal, exponential and Cauchy distribution functions.
  4. The estimates. \(\bar x\) and \(s\) for the normal, \(1/\bar x\) for the exponential, the median and half the interquartile range for the Cauchy.
  5. Expected frequencies from the distribution function. \(n[F(b) - F(a)]\) for each class, the two outer classes opened to \(\pm\infty\), printed beside the observed.
  6. Chi-square for each fit. \(\chi^{2}\) for each fit, its degrees of freedom and its \(p\) value from tails.py.
THE METHOD

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\).

PYTHON USED
Function or statementWhat it does
math.erfthe error function, for the normal c.d.f.
math.atanthe 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_upperthe \(\chi^{2}\) tail area from Practical 0

4. Programme

PRACTICAL 9 — p9.py
# 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}")

5. Execution and Results

Saved as p9.py and run with python3 p9.py, it printed:

OUTPUT
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\).

Corrected. The exponential's \(p\) value was printed as \(1.332\times10^{-15}\), the floor of the old chi2_upper; the true value is \(3.99\times10^{-56}\) (see the note in Practical 0). The decision is the same.
RESULT

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.

Practical 10: Correlation and Both Regression Lines

1. Question

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\)65636764686270666867
\(y\)68666865696668657167

2. Aim

Compute the correlation coefficient and both regression lines, and verify the three relations between them.

3. Steps

  1. Enter the data; the sums of squares and products. Store \(x\) and \(y\); compute the means, \(S_{xx}\), \(S_{yy}\) and \(S_{xy}\).
  2. The correlation. \(r = S_{xy}/\sqrt{S_{xx}S_{yy}}\).
  3. Both regression lines, and the checks. The two slopes and intercepts; check \(b_{yx}b_{xy} = r^{2}\), and evaluate each line at the other's mean.
  4. The angle between the lines. \(\tan\theta = \left|\frac{m_2 - m_1}{1 + m_1m_2}\right|\) with \(m_1 = b_{yx}\) and \(m_2 = 1/b_{xy}\), the slopes of the two lines in the \((x, y)\) plane.
  5. The residual sd and a prediction. \(\sqrt{(S_{yy} - b_{yx}S_{xy})/(n-2)}\), and the line \(y\) on \(x\) at \(x = 69\).
THE METHOD \[ r = \frac{S_{xy}}{\sqrt{S_{xx}S_{yy}}}, \qquad b_{yx} = \frac{S_{xy}}{S_{xx}}, \qquad b_{xy} = \frac{S_{xy}}{S_{yy}}, \]

and the three facts the program checks:

  1. \(b_{yx}b_{xy} = r^{2}\), so the correlation is the geometric mean of the two regression coefficients.
  2. Both lines pass through \(\left(\bar x, \bar y\right)\), which the output confirms by evaluating each at the other's mean.
  3. The angle between them measures the correlation. It is zero when \(|r| = 1\) — the two lines coincide — and a right angle when \(r = 0\).
PYTHON USED
Function or statementWhat it does
zip(x, y)pair each \(x\) with its \(y\)
math.sqrtthe square roots in \(r\) and the residual sd
math.atan, math.degreesthe angle between the lines, in degrees

4. Programme

PRACTICAL 10 — p10.py
# 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}")

5. Execution and Results

Saved as p10.py and run with python3 p10.py, it printed:

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

RESULT

\(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.

Practical 11: Testing Means, Variances and Correlations

1. Question

Carry out, at the 5% level:

  1. a test of \(\mu = 50\) for 48, 52, 55, 49, 53, 51, 47, 54, 50, 52, 56, 50;
  2. a test of equal means for A: 23, 27, 25, 29, 24, 26, 28 and B: 31, 28, 33, 30, 35, 29;
  3. a paired test for eight subjects measured before (72, 68, 75, 70, 66, 74, 69, 71) and after (70, 65, 71, 68, 65, 70, 67, 69);
  4. a test of \(\sigma^{2} = 6\) for the sample in (1);
  5. a test of equal variances for A and B;
  6. a test of \(\rho = 0\), and
  7. a test of \(\rho = 0.8\), for the ten pairs of Practical 10.

2. Aim

Carry out seven tests, with every tail area computed rather than read from a table.

3. Steps

  1. Size, mean and variance (n - 1 divisor). A function that returns \(n\), \(\bar x\) and \(s^{2}\) with divisor \(n - 1\).
  2. One-sample t. \(t = (\bar x - \mu_0)/(s/\sqrt n)\) on \(n - 1\) df.
  3. Two independent samples, pooled variance. The pooled variance, then \(t\) on \(n_1 + n_2 - 2\) df.
  4. Paired t. The differences \(d\), then \(t = \bar d/(s_d/\sqrt n)\) on \(n - 1\) df.
  5. One variance against a stated value. \(\chi^{2} = (n-1)s^{2}/\sigma_0^{2}\) on \(n - 1\) df, two-sided.
  6. Two variances. \(F\), the larger variance over the smaller, two-sided.
  7. A correlation against zero. \(t = r\sqrt{(n-2)/(1-r^{2})}\) on \(n - 2\) df.
  8. A correlation against a stated non-zero value, by Fisher's z. Fisher's \(z\) for \(r\) and for \(\rho_0\), and \((z - z_0)\sqrt{n-3}\) against \(N(0,1)\).
THE METHOD
TestStatisticNull 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)\)
PYTHON USED
Function or statementWhat it does
from tails import t_two_sided, F_upper, chi2_upper, normal_two_sidedthe 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.logFisher's \(z\) transformation

4. Programme

PRACTICAL 11 — p11.py
# 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}")

5. Execution and Results

Saved as p11.py and run with python3 p11.py, it printed:

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

RESULT
  1. \(t = 1.766\) on 11 df, \(p = 0.105\): \(\mu = 50\) is not rejected.
  2. \(t = -3.786\) on 11 df, \(p = 0.003\): the means of A and B differ.
  3. \(t = 6.614\) on 7 df, \(p = 0.0003\): the mean fell, by 2.5.
  4. \(\chi^{2} = 14.15\) on 11 df, \(p = 0.449\): \(\sigma^{2} = 6\) is not rejected.
  5. \(F = 1.457\) on (5, 6) df, \(p = 0.654\): the variances may be taken as equal, which justifies the pooled test in (2).
  6. \(r = 0.637\), \(t = 2.336\) on 8 df, \(p = 0.048\): \(\rho = 0\) is rejected, just.
  7. Fisher's \(z\) statistic \(-0.915\), \(p = 0.360\): \(\rho = 0.8\) is not rejected.

Practical 12: One-Way and Two-Way Analysis of Variance

1. Question

(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 1Block 2Block 3Block 4
Variety 118222016
Variety 223252621
Variety 315191814

Test whether the varieties differ and whether the blocks differ.

2. Aim

Produce both analysis of variance tables from the correction factor upward.

3. Steps

  1. Print an analysis of variance table. A function that prints the table: for each source the SS, df, MS, \(F\) against the error mean square, and its \(p\) value from tails.py.
  2. One-way: correction factor, then the sums of squares. \(G\), \(CF = G^{2}/N\), the total and treatment sums of squares, and the error by subtraction.
  3. Two-way, one observation per cell. The row and column totals, their sums of squares, and the error by subtraction on \((r-1)(c-1)\) df.
  4. Check that the parts add back to the total. Print the sum of the parts beside the total.
THE METHOD \[ CF = \frac{G^{2}}{N}, \qquad SS_{\text{total}} = \sum x^{2} - CF, \] \[ SS_{\text{treatments}} = \sum_i \frac{T_i^{2}}{n_i} - CF, \qquad SS_{\text{error}} = SS_{\text{total}} - SS_{\text{treatments}}, \]

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.

PYTHON USED
Function or statementWhat it does
from tails import F_upperthe \(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

4. Programme

PRACTICAL 12 — p12.py
# 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}")

5. Execution and Results

Saved as p12.py and run with python3 p12.py, it printed:

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

RESULT

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

How Marks Are Lost

THE RECURRING ERRORS

What the Practical Record Should Contain

FOR EACH PROGRAM
  1. Question — the problem as set, with its input data.
  2. Aim — in one line.
  3. Steps — the formula or algorithm, written out before any code, in numbered steps.
  4. Programme — the program, with the functions named for what they do and each step marked by a comment.
  5. Execution and Results — the output, as it was actually printed (not as it should have been); the check, a second route to the same number or an identity that must hold; and the conclusion in words.