---
title: "Statistics for a published administrative table"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Statistics for a published administrative table}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
library(rmoriebricklayer)
```

An open-data extract from a criminal-justice system has a particular
shape: a handful of fiscal years, counts rather than measurements,
categories reported as bands rather than values, and a region code with
no geometry attached. The general-purpose toolkits assume none of that.
This vignette covers the functions that do.

The running example is the shape Ontario's inmate datasets have -- one
row per placement, keyed on the fiscal year's end year, with banded
counts and a region.

## Bands

Published categories are intervals. Anything computed from them rests on
an assumption about where inside each band the mass sits, and on a
second assumption about where the open top band ends.

```{r}
bands <- parse_bands(c("1", "2 to 5", "6 to 10", "Greater than 10"))
bands
```

Note what the bounds say. `"Greater than 10"` starts at 11, because the
quantity is a count of placements; `"10 or more"` would start at 10. One
whole placement separates the two readings, and only the label says
which was meant.

The representative value needs a rule, and the open band needs a cap:

```{r}
band_values(bands)
```

The last row is marked `assumed`, because a band with no upper bound has
no midpoint. Everything downstream inherits that assumption, so measure
how much it matters:

```{r}
counts <- c(1200, 430, 110, 38)
band_sensitivity(bands, counts)
```

Read the span, not the middle row. A statistic that moves by more than a
tenth of itself across plausible caps is a property of the assumption as
much as of the data, and the honest report is the range.

## Concentration

The recurring question is whether a few individuals account for most of
the total.

```{r}
placements <- expand_bands(bands, counts, open_upper_cap = 25)
gini(placements)
top_share(placements, c(0.01, 0.05, 0.1))
```

Gini has a ceiling that depends on the number of units: for `n` units the
maximum is `1 - 1/n`, not 1. A Gini of 0.9 means something different
across ten units than across ten thousand.

```{r}
c(ten = 1 - 1 / 10, thousand = 1 - 1 / 1000)
```

If the tail looks heavy, fit it rather than asserting it -- and read the
goodness-of-fit distance before the exponent:

```{r}
fit <- hill_tail_index(placements, x_min = 1)
c(alpha = round(fit$alpha, 2), ks = round(fit$ks, 3),
  reliable = fit$reliable)
```

A large `ks` means the tail is not a power law, whatever `alpha` came out
as. The estimator maximises the exact discrete likelihood: the
closed-form continuity correction usually quoted is an asymptotic
approximation in the threshold, and at a threshold of one -- where counts
start -- it is badly biased.

## Trend across a handful of years

Five annual points support a rank test and a resistant slope. They do
not support a model with an autocorrelation structure.

```{r}
y <- c(402, 377, 190, 268, 331)
tt <- trend_test(y)
c(tau = tt$tau, p = round(tt$p_value, 4), slope = tt$slope)
tt$method
```

The p-value is exact, by enumerating all 120 orderings. For counts, the
trend is better expressed as a multiplicative change per year:

```{r}
ct <- count_trend(y)
c(rate_ratio = round(ct$rate_ratio, 3),
  lower = round(ct$lower, 3), upper = round(ct$upper, 3),
  dispersion = round(ct$dispersion, 2))
```

The dispersion is reported because a Poisson interval assumes it is one.
Above 1.5 the interval is widened to the quasi-Poisson one rather than
being left too narrow.

### Did it change when the policy changed?

```{r}
sc <- step_change(c(100, 104, 98, 60, 63, 58), x = 2018:2023)
c(after = sc$break_after, before = sc$before, after_mean = sc$after,
  p = round(sc$p_value, 4))
```

The p-value comes from the permutation distribution of the **maximum**
over splits, not from the best split's own test. That distinction is the
difference between a change-point test and a way of finding a break in
noise. It also has a consequence worth stating plainly: six points
cannot produce a p-value below about 0.1, however clean the step, because
only 72 of the 720 orderings separate three low values from three high
ones.

```{r}
(1 + 2 * factorial(3)^2) / (1 + factorial(6))
```

## Points, and the regions that contain them

Before a regional count means anything, something has to have decided
which region each facility is in. That decision is a region map: built
once, from coordinates and a boundary file, and then read by everything
downstream.

Which is exactly why recomputing the downstream tables cannot check it.
An error in the region map reproduces perfectly in every table built on
it, because those tables are where it is read. The region map has to be
checked on its own terms.

```{r}
cw <- data.frame(
  facility = c("North Jail", "South Jail", "Hill Jail", "Lake Jail"),
  region   = c("3557", "3520", "3553", "3520"),
  stringsAsFactors = FALSE
)
pop <- data.frame(
  region     = c("3557", "3520", "3553", "3552", "3519"),
  population = c(114094, 3025647, 171568, 22746, 1173334),
  stringsAsFactors = FALSE
)
```

`region_map_integrity()` finds the failures that a comparison against
published output cannot, because they would be present on both sides of
it: a facility assigned two regions, a facility assigned none, a region
code that belongs to a different province.

```{r}
region_map_integrity(cw, "facility", "region", regions = pop$region)
```

Every check is written so that zero is the passing value, which is what
makes the frame safe to record directly into a manifest.

```{r}
bad <- cw
bad$region[3] <- "2406"          # a code from another province
region_map_integrity(bad, "facility", "region", regions = pop$region)
```

### Recomputing is not the same as checking

`region_map_compare()` matches a recomputed region map against the
published one and compares them cell by cell. Numeric columns go through
`all.equal()`, so a coordinate that survived a round trip through text is
not reported as a change.

```{r}
recomputed <- cw
recomputed$region[2] <- "3519"
region_map_compare(cw, recomputed, "facility")
```

That is worth having, and it is worth being clear about what it
establishes: the recomputation reproduced the published assignment. Run
the same method against the same boundary file and a *definitional*
error reproduces perfectly too. Both sides move together.

### A second route can disagree

The check that can catch an error in the original method is one that
does not use that method. Derive the region a different way -- from a
place name, a postal geography, an administrative lookup -- and compare.

A name-based route is the usual second route, and it has understood
weaknesses. Names collide. In Ontario, census division 3552 is named
"Sudbury" and is a thinly populated district; census division 3553 is
named "Greater Sudbury" and is the city inside it. A facility in the city
has a postal address in Sudbury, and a name route sends it to the
district.

```{r}
route <- c("North Jail" = "3557", "South Jail" = "3520",
           "Hill Jail" = "3552", "Lake Jail" = "3520")
region_map_second_route(cw, "facility", "region", route,
                       known = "Hill Jail")
```

The known-bad case is listed by NAME rather than allowed for by widening
a tolerance, and the distinction matters. A tolerance of "one
disagreement is acceptable" would swallow a second, different
disagreement silently. Naming it means `sum(!x$known)` stays the number
that must be zero.

```{r}
route["South Jail"] <- "3599"    # an assignment nobody documented
d <- region_map_second_route(cw, "facility", "region", route,
                            known = "Hill Jail")
d
sum(!d$known)
```

Facilities the route does not cover are skipped rather than counted as
disagreements, so a partial second route is still usable. Record how
many it covered: a route that quietly stops matching anything leaves the
disagreement check passing over an empty set.

```{r}
length(route)
```

`region_map_from_points()` does the geometric recompute, projecting the
points onto the boundary file's own coordinate system rather than the
reverse, since reprojecting polygons moves their edges. It needs `sf` and
a boundary file and returns `NULL` without either, so a verification
script records the check as unavailable instead of failing over an
optional dependency. It also returns `n_regions` rather than silently
resolving it: any value but 1 means the point fell outside the geography,
or the boundaries overlap.

### The coverage share is not a denominator

With the region map checked, the obvious next step is a rate per region.
`region_coverage()` reports the ingredients and, every time it prints,
declines to supply that rate.

```{r}
units <- vapply(pop$region, function(r) sum(cw$region == r), numeric(1))
cov <- region_coverage(pop$region, pop$population, units)
cov
```

Two of the five regions hold no facility at all. It is tempting to read
their population as uncovered, and to build a regional rate on the
population of the regions that do hold one. Both readings are wrong, for
the same reason.

A facility serves a catchment: an administrative fact about where people
are sent from, which a coordinate does not state and cannot imply. Some
geographies were never meant to have one facility each. So summing the
populations of facility-holding regions gives a denominator covering part
of the territory while the numerator counts people drawn from all of it.
Every rate built that way is inflated, and inflated unevenly -- a dense
region holding one facility and a sparse region holding seven distort it
in opposite directions.

```{r}
a <- attr(cov, "coverage")
c(covered = a$covered_population, uncovered = a$uncovered_population,
  share = round(a$covered_share, 1))
```

Where the numerator and the denominator have to cover the same people,
the defensible figure is the whole-territory one. That is the next
section.

## Regions

A region code is an areal unit, not a coordinate. Three things go wrong
with a table of regional counts: the regions hold different numbers of
people, they hold different *kinds* of people, and a small region's rate
is mostly noise.

```{r}
d <- expand.grid(
  region = c("Central", "Eastern", "Northern", "Toronto", "Western"),
  age = c("18 to 24", "25 to 49", "50+"),
  stringsAsFactors = FALSE
)
d$pop <- rep(c(4000, 3000, 800, 5000, 2500), 3) *
  rep(c(0.3, 0.55, 0.15), each = 5)
set.seed(13)
rate <- c(Central = 0.006, Eastern = 0.011, Northern = 0.010,
          Toronto = 0.016, Western = 0.012)
d$n <- rpois(nrow(d), lambda = d$pop * rate[d$region])
```

Indirect standardisation handles the second problem: the expected count
is what each region would have under the overall rate in every age band,
given its own age composition.

```{r}
e <- expected_counts(d$n, d$pop, d$region, strata = d$age)
e
```

The expected counts total the observed ones. That identity is what makes
them a standardisation rather than a prediction.

```{r}
c(observed = sum(e$observed), expected = sum(e$expected))
```

The ratio, with an exact interval -- the counts here are small, and a
normal approximation on a count of six is a decoration rather than an
interval:

```{r}
sir(e$observed, e$expected, e$area)
```

And the third problem. A league table of rates ranks the small regions to
both ends by construction, so borrow strength across regions:

```{r}
eb_rates(e$observed, e$expected, e$area)
```

`shrinkage` is how far each estimate was pulled toward the overall rate.
It is governed by the expected count, which is to say by how much
information the region carries -- not by how extreme its ratio was.

The funnel plot shows the reader the same thing directly: what range a
region's ratio could take, given its size, if it were no different from
anywhere else.

```{r}
funnel_limits(c(5, 20, 50, 200))
```

### Autocorrelation, if you have a neighbour list

```{r}
nb <- list(c(2L, 4L), c(1L, 3L), c(2L, 5L), c(1L, 5L), c(3L, 4L))
set.seed(1)
mi <- morans_i(e$observed / e$expected, nb, n_perm = 999L)
c(I = round(mi$I, 3), expectation = round(mi$expectation, 3),
  p = mi$p_value)
```

The neighbour list has to be supplied. An extract keyed on a region ships
no geometry, and a guessed adjacency would make the answer a property of
the guess. Note too that the null expectation is `-1/(n - 1)`, not zero:
with five regions, a small negative `I` is what independence looks like.

```{r}
-1 / (5 - 1)
```
