---
title: 'Applying Hypothesis Testing'
source: 'https://academia.sh/en/courses/statistical-analysis-and-exploratory-data-analysis/applying-hypothesis-testing'
course: 'Statistical Analysis and Exploratory Data Analysis'
language: en
updated: '2026-10-07T04:31:12+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Defines the null hypothesis, the p-value and the significance level through permutation and produces the p-value by counting runs'
    - 'Tests the same two groups both as independent and as paired, checks the pairing assumption with a correlation and prints the two p-values side by side'
    - 'Shows with numbers that the p-value is not the size of the effect and that the same effect gives a different p when the observation count changes'
---

# Applying Hypothesis Testing

Among the 1,101 subscribers read in both periods the observed difference is 0.957 m³, and this single difference gives two separate p-values: in the independent assumption 24 of 2,000 permutations exceed the difference and p comes out as 0.01249, in the paired assumption 5 exceed it and p becomes 0.00300. The assumption check shows with a correlation of 0.213 that the pairing is real; when the pairing is broken p returns to 0.01099. The effect sizes, 0.112 and 0.089, stand next to p. In a quarter of the same dataset, 275 pairs, the difference is larger at 1.132 m³, yet p comes out thirty-five times larger at 0.10695.

The previous lesson computed the interval of the difference itself and saw that zero stayed outside the
interval. That stood close to a decision but it was not a test. The question a test asks is different:
when there is no difference at all, **in how many runs out of how many** does a difference of the
observed size appear.

In this course the answer to that question is not read from a table; it is counted by running. The
first lesson of the exploratory analysis topic randomly redistributed the region labels and counted in
how many runs the difference between east and north appeared; here the same method is named a test, and
the main measure is this: **the choice of test is an assumption, and when the assumption changes the
p-value moves.** The same two groups, the same observed difference, two p-values.

- **AS19. The null hypothesis is not a sentence but a procedure.** The assumption "there is no
  difference between the two groups" means that the labels are interchangeable among the observations;
  the **permutation test** does exactly this and recomputes the difference at every shuffle.
- **AS20. The p-value** is how many of the shuffled datasets produced a difference as large as the
  observed one or larger. The observed run also takes part in the count, so the numerator and the
  denominator are each increased by one.
- **AS21. The significance level** is a threshold written **before** the test. A threshold chosen after
  the result is seen is not a threshold but a result.
- **AS22.** The choice of test is an assumption, and the assumption can be checked. Whether the pairing
  is real is measured with the correlation of the paired readings.
- **AS23.** The same observed difference gives two p-values under two assumptions. The difference does
  not change, the p changes.
- **AS24. The p-value is not the size of the effect.** The effect size, the difference divided by the
  standard deviation, is written next to p as a separate number.
- **AS25. The p-value is not the probability of the null hypothesis either.** Moreover its resolution
  depends on the run count: the smallest p-value that can be written with 2,000 permutations is 0.00050.

## The Null Hypothesis Is a Procedure

```python
# dataset.py — MODEL: the fictional measurement network of K03-K04 is regenerated with the same seed.
# A long-format reading table of 3,199 rows, 1,329 subscribers, three periods, five regions.
import math

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
PERIODS = ["2026-01", "2026-02", "2026-03"]
REGION = [("north", 0.28, 21), ("south", 0.22, 17), ("east", 0.18, 26),
          ("west", 0.14, 14), ("central", 0.18, 23)]


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


_kept, _dropped = [], []
for i in range(RAW):
    r = rng(SEED + i)
    b = REGION[pick(r(), [x[1] for x in REGION])]
    pick(r(), [0.06, 0.24, 0.30, 0.24, 0.11, 0.05])
    pick(r(), [0.08, 0.14, 0.27, 0.34, 0.17])
    (_dropped if r() < 0.046 else _kept).append(
        {"subscriber_id": 10001 + i, "region": b[0], "base": b[2]})
VALID = {a["subscriber_id"] for a in _kept}

READINGS = []
for a in _kept + _dropped:
    r = rng(SEED + 7000 + a["subscriber_id"])
    for d in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        t = 0.0 if r() < 0.038 else math.floor(
            a["base"] * math.exp((r() + r() + r() - 1.5) * 0.62)
            * (1 - d * 0.05) * 100 + 0.5) / 100
        if a["subscriber_id"] not in VALID:
            r()
            continue
        READINGS.append({"subscriber_id": a["subscriber_id"], "region": a["region"],
                         "period": PERIODS[d], "consumption_m3": t})
        r()


def mean(v):
    return sum(v) / len(v)


PERMUTATIONS = 2000
by_subscriber = {}
for o in READINGS:
    by_subscriber.setdefault(o["subscriber_id"], {})[o["period"]] = o["consumption_m3"]
PAIRS = [(v["2026-01"], v["2026-02"]) for v in by_subscriber.values()
         if "2026-01" in v and "2026-02" in v]
first = [x for x, _ in PAIRS]
second = [y for _, y in PAIRS]
diff = [x - y for x, y in PAIRS]
OBSERVED = mean(diff)
print(f"subscribers read in both periods: {len(PAIRS)}")
print(f"2026-01 mean {mean(first):.3f} m3, 2026-02 mean {mean(second):.3f} m3")
print(f"observed difference {OBSERVED:.3f} m3")


def shuffle(v, draw):
    h = v[:]
    for i in range(len(h) - 1, 0, -1):
        j = int(draw() * (i + 1))
        h[i], h[j] = h[j], h[i]
    return h


draw = rng(SEED + 401)
everything, n1 = first + second, len(first)
exceeded = 0
for _ in range(PERMUTATIONS):
    h = shuffle(everything, draw)
    if abs(mean(h[:n1]) - mean(h[n1:])) >= abs(OBSERVED):
        exceeded += 1
p_independent = (exceeded + 1) / (PERMUTATIONS + 1)
print(f"independent assumption: {exceeded} of {PERMUTATIONS} permutations exceeded the observed difference, "
      f"p = {p_independent:.5f}")
```

```
subscribers read in both periods: 1101
2026-01 mean 20.638 m3, 2026-02 mean 19.681 m3
observed difference 0.957 m3
independent assumption: 24 of 2000 permutations exceeded the observed difference, p = 0.01249
```

The question is a report question: did consumption fall from the first period to the second. There are
1,101 subscribers who could be read in both periods, the means are 20.638 and 19.681 m³, and the
observed difference is 0.957 m³. The dataset is fictional and regenerated with the same seed from the
measurement network of K03–K04; that only 1,101 of the 1,329 subscribers could be read in both periods
is the dataset's own deficiency, not a selection.

The null hypothesis is set up here not as a sentence but as a procedure. Saying "the period label has
no effect" means accepting that which period each of the 2,202 readings belongs to is random. The code
does exactly this: it puts all the readings in a single bag, shuffles it, counts the first 1,101 as the
first period, and recomputes the difference. This is repeated 2,000 times.

The p-value is a **count** from this run: 24 of the permutations produced a difference as large as the
observed 0.957 m³ or larger. One is added to the numerator and the denominator, because the observed
arrangement is itself one of the possible arrangements; p comes out as 0.01249. If a significance level
of 5% was written before the test, this result is below the threshold.

That the significance level is written **before** is the load-bearing part of this sentence. When the
threshold is chosen after the result, it stops being a decision rule and turns into a label that
repeats the result itself; in that case the test has eliminated nothing. The number of the threshold is
also a convention, not a measurement: in this dataset the choice between 5% and 1% does more work than
which of the two assumptions below is looked at.

## The Pairing Assumption

```python
# paired.py — the same dataset, another assumption: every row is the two readings of one subscriber.
# This time the null hypothesis means "within every pair the sign of the difference is random".
draw = rng(SEED + 403)
exceeded2 = 0
for _ in range(PERMUTATIONS):
    t = sum(f if draw() < 0.5 else -f for f in diff) / len(diff)
    if abs(t) >= abs(OBSERVED):
        exceeded2 += 1
p_paired = (exceeded2 + 1) / (PERMUTATIONS + 1)


def std_dev(v):
    m = mean(v)
    return math.sqrt(sum((x - m) ** 2 for x in v) / (len(v) - 1))


pooled = math.sqrt((std_dev(first) ** 2 + std_dev(second) ** 2) / 2)
ma, mb = mean(first), mean(second)
cov = sum((x - ma) * (y - mb) for x, y in PAIRS) / (len(PAIRS) - 1)
print(f"paired assumption: {exceeded2} permutations exceeded the observed difference, p = {p_paired:.5f}")
print(f"assumption check: correlation of the paired readings {cov / (std_dev(first) * std_dev(second)):.3f}")
print(f"the difference is {OBSERVED:.3f} m3 in both; effect size "
      f"{OBSERVED / pooled:.3f} (independent) and {OBSERVED / std_dev(diff):.3f} (paired)")
print(f"p ratio {p_independent / p_paired:.2f} times")
```

```
paired assumption: 5 permutations exceeded the observed difference, p = 0.00300
assumption check: correlation of the paired readings 0.213
the difference is 0.957 m3 in both; effect size 0.112 (independent) and 0.089 (paired)
p ratio 4.17 times
```

The same 2,202 readings were counted with a second assumption. This time every row is the two readings
of one subscriber, and the null hypothesis takes the form "within every pair the sign of the difference
is random". The shuffle changes accordingly: the labels swap places not between the groups but within
every pair. The result is p 0.00300.

The observed difference is 0.957 m³ under both assumptions and has not moved by a single cubic meter. The only
number that moved is the p-value, and it shrank 4.17 times. The whole difference between them comes
from the assumption taken while choosing the test. Both assumptions stay below the 5% threshold, but at the
1% threshold the decisions part ways.

The assumption check is in the second line. For the pairing to bring a gain, the two readings of the
same subscriber must be related to each other; the measured correlation is 0.213. This means that the
subscribers' consumption level is partly preserved across periods, and it explains why the paired assumption
gave a smaller p: the dispersion between subscribers is subtracted from the computation.

The effect size stands next to p on the same line: 0.112 and 0.089. Both are the difference divided by
one standard deviation, and both are small. What is being talked about is a decline of 4.6% of mean
consumption; the smallness of the p-value does not enlarge this decline.

## Pairing Does Not Come from the Procedure

```python
# fake.py — pairing is not a procedure but a fact: if the second-period readings
# are matched with other subscribers the difference stays the same, the gain disappears.
draw = rng(SEED + 404)
fake_second = shuffle(second, draw)
fake_diff = [x - y for x, y in zip(first, fake_second)]
draw = rng(SEED + 405)
exceeded3 = 0
for _ in range(PERMUTATIONS):
    t = sum(f if draw() < 0.5 else -f for f in fake_diff) / len(fake_diff)
    if abs(t) >= abs(mean(fake_diff)):
        exceeded3 += 1
print(f"fake pairing: same difference {mean(fake_diff):.3f} m3, "
      f"p = {(exceeded3 + 1) / (PERMUTATIONS + 1):.5f}")
print(f"standard deviation of the differences: with the real pairing {std_dev(diff):.3f}, "
      f"with the fake {std_dev(fake_diff):.3f} m3")
```

```
fake pairing: same difference 0.957 m3, p = 0.01099
standard deviation of the differences: with the real pairing 10.753, with the fake 12.275 m3
```

It can be thought that the small p-value of the paired test was produced by the test itself. This run
tests that: the second-period readings are shuffled and matched with other subscribers, and then
exactly the same paired test is applied.

The observed difference does not change, because the mean of the two groups has stayed the same: again
0.957 m³. But p rises to 0.01099 and returns to the same region as the 0.01249 that the independent
count gave. The reason is in the last line: the standard deviation of the differences has risen from
10.753 to 12.275 m³, that is, the dispersion the real pairing had lowered has come back.

The result gives the rule about the choice of test. The paired test is not a more powerful method;
**if the pairing is real** it is more powerful. Inventing a pairing that does not exist does not shrink
the p-value, it only moves the computation to the wrong place. This is why the assumption check is
written not as an appendix to the test but as its precondition. The check itself is not expensive
either: here a single correlation number and a single standard deviation comparison were enough, and
both are too small to be counted next to the cost of the test.

## What the p-Value Is Not

```python
# scale.py — the same effect, a different observation count. The subsets are chosen
# nested and the same paired test is applied to each.
draw = rng(SEED + 406)
order = shuffle(list(range(len(PAIRS))), draw)
for divisor in (4, 2, 1):
    sub = [diff[i] for i in sorted(order[:len(PAIRS) // divisor])]
    r2 = rng(SEED + 403)
    over4 = 0
    for _ in range(PERMUTATIONS):
        t = sum(f if r2() < 0.5 else -f for f in sub) / len(sub)
        if abs(t) >= abs(mean(sub)):
            over4 += 1
    print(f"n={len(sub):4d} difference {mean(sub):6.3f} m3 effect {mean(sub) / std_dev(sub):.3f} "
          f"p = {(over4 + 1) / (PERMUTATIONS + 1):.5f}")
print(f"smallest p that can be written with {PERMUTATIONS} permutations = {1 / (PERMUTATIONS + 1):.5f}")
```

```
n= 275 difference  1.132 m3 effect 0.099 p = 0.10695
n= 550 difference  1.192 m3 effect 0.110 p = 0.01149
n=1101 difference  0.957 m3 effect 0.089 p = 0.00300
smallest p that can be written with 2000 permutations = 0.00050
```

The three lines show directly what the p-value is not. In the subset of a quarter the observed
difference is 1.132 m³ — **larger** than the 0.957 m³ of the full dataset — and the effect size, 0.099,
is also above 0.089. Even so the p-value is 0.10695, that is, thirty-five times the full dataset's
0.00300 and above the 5% threshold.

The same three lines are also read the other way: as p shrank, the effect did not grow. The line that
gives the smallest p is the line that gives the smallest effect. The p-value measures not the size of
the difference but how far the difference, together with the observation count, is from chance. This is
why p is not reported alone; the observation count and the effect size are written next to it.

The second wrong reading closes here too: p is not the probability that the null hypothesis is true.
What it measures is how often a difference this large is produced when the null hypothesis is true —
the number 0.00300 does not mean "the chance of no difference is three in a thousand", it means "when
there is no difference, a result like this came up in 5 of 2,000 permutations".

The last line writes the limit of the number itself: the smallest p that can be produced with 2,000
permutations is 0.00050. In a test where no shuffle could exceed the observed difference, what can be
written is not "p is zero" but "p is smaller than 0.00050". The run count sets the resolution of the
margin.

## Summary

- The null hypothesis in the permutation test is a procedure: the period labels are randomly
  redistributed 2,000 times, the difference is recomputed each time, and the runs that exceed the
  observed difference are counted.
- In the same 1,101 pairs the observed difference is 0.957 m³ and does not change; the p-value comes
  out as 0.01249 in the independent assumption and 0.00300 in the paired assumption, that is, the assumption moves
  p by 4.17 times.
- The pairing assumption is checked: the correlation of the paired readings is 0.213. When the pairing
  is broken the standard deviation of the differences rises from 10.753 to 12.275 m³ and p returns to
  the independent assumption with 0.01099.
- The p-value is not the effect size: in the subset of 275 pairs the difference is 1.132 m³ and the
  effect is larger at 0.099, yet p is 0.10695, thirty-five times that of the full dataset.
- The p-value is not the probability of the null hypothesis either, and its resolution depends on the
  run count; the smallest p that can be written with 2,000 permutations is 0.00050.

## Next Step

In this lesson a single question was asked and a single p-value was written: is there a difference
between the two periods. Once the question is chosen, the test itself is orderly work and, together with
the assumption check, gives a defensible result. In a real analysis, however, the question is not
single. Which metric will be counted from the same experiment dataset, which breakdown will be opened,
which observation window will be taken and what will be done about an unbalanced assignment are chosen
one by one; every choice means a new test, and the number of questions enlarges the margin. The next
lesson counts how many different decisions come out of the same experiment dataset and measures how
much of this margin pre-registration closes.
