---
title: "Year-over-year change, and the three ways it goes wrong"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Year-over-year change, and the three ways it goes wrong}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

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

A year-over-year percent change is the most-quoted number in open-data
reporting and the least qualified. `yoy()` computes it, and refuses to
compute it in the three cases where the figure would describe something
other than the data.

## The data

Ontario's inmate data is published one row per placement, by fiscal year
and by group. A realistic shape:

```{r}
seg <- data.frame(
  EndFiscalYear = rep(2019:2023, each = 2),
  Gender = rep(c("Female", "Male"), 5),
  Number_Of_Placements = c(31, 402, 28, 377, 12, 190, 19, 268, 24, 331)
)
```

Segregation placements rising is bad news, so the direction is declared.
That affects colour and the verdict column, never the arithmetic.

```{r}
y <- yoy(seg,
  value = Number_Of_Placements,
  period = EndFiscalYear,
  by = "Gender",
  direction = "lower_is_better"
)
y
```

The interval is exact. Conditional on the two periods' total, the current
count is binomial, so the ratio of the two has a Clopper-Pearson
interval, which is the same construction `stats::poisson.test()` uses:

```{r}
stats::poisson.test(c(331, 268), c(1, 1))$conf.int
```

## The first failure: matching on row order

With a year missing, comparing row *i* with row *i - 1* compares 2023
with 2021 and labels it a one-year change. Matching on the period's own
value cannot do that:

```{r}
gap <- data.frame(year = c(2019, 2020, 2022, 2023), n = c(100, 120, 140, 150))
yoy(gap, value = n, period = year)
```

2021 appears as a row with no value, and 2022 has no comparison period
rather than a quietly three-year one.

## The second failure: a percent off a small base

Two placements becoming twenty is a 900% rise and also nothing at all.
The number describes how small the denominator was:

```{r}
tiny <- data.frame(year = 2019:2021, n = c(2, 20, 25))
yoy(tiny, value = n, period = year)
```

The change of `+18` is still reported, and so is the direction: those are
facts. Only the percent is withheld, with the reason attached. Lower the
gate if the small base is the point:

```{r}
as.data.frame(yoy(tiny, value = n, period = year, min_base = 0))$pct_change
```

## The third failure: a percent of a percent

If a rate moves from 4% to 5% that is one percentage point. Calling it
25% answers a different question:

```{r}
rate <- data.frame(year = 2019:2023, share = c(4.1, 4.6, 5.2, 5.0, 5.4))
yoy(rate, value = share, period = year, units = "percent")
```

The column is named `points`, and no count interval is offered, because
these are not counts.

## Seasonal series

A monthly series compares with the same month a year earlier. The lag
follows the series' own frequency, so January is never put against
December:

```{r}
m <- stats::ts(c(10:21, 20:31), start = c(2021, 1), frequency = 12)
head(as.data.frame(yoy(m, min_base = 0))[12:14, c("period", "value",
                                                  "previous", "change")], 3)
```

## Across the span

```{r}
yoy_summary(y)
```

`cagr_pct` is the compound rate per period: applying it across the span
returns `total_pct` exactly.

## Rates, when the denominator moves too

`yoy()` compares counts. A count is not comparable across places of
different size, or across years whose populations differ, and the fix
is a rate -- but a rate is not made trustworthy by being a rate.

```{r}
stops <- data.frame(
  division = c("North", "South", "East"),
  stops = c(412, 77, 3),
  residents = c(120000, 41000, 9500))

rate(stops, stops, residents, by = "division", per = "100k")
```

East's interval is wider than its own estimate. That is the honest
reading of three events, and it is the reason the interval is printed
beside the rate rather than left to the reader to imagine. The interval
is the exact Poisson one, so a count of zero has a lower limit of
exactly zero instead of a negative rate.

A share is a different quantity. Its denominator is the total of the
same events, not a population, so shares over a complete grouping sum
to 100:

```{r}
share(stops, stops, by = "division")
```

"40% of stops" and "40 stops per 1,000 residents" are different claims,
and a table that labels one as the other is wrong however carefully the
arithmetic was done. That is why these are separate functions.

For the change in a rate, both the counts and the denominators move:

```{r}
d <- data.frame(
  year = rep(2021:2023, each = 2),
  division = rep(c("North", "South"), 3),
  stops = c(400, 70, 430, 66, 455, 61),
  residents = c(120000, 41000, 122000, 41500, 125000, 42000))

rate_change(d, stops, residents, year, by = "division", per = "100k")
```

North's count rose, and so did its population, so the rate change is
smaller than the count change -- which is the whole reason to look at
the rate. The interval conditions on the two counts and corrects for
the ratio of the two exposures; with equal populations it reduces to
exactly the conditional-binomial interval `yoy()` uses above.

## Output

The same object writes to six formats. The format follows the file name.

```{r}
dir <- tempdir()
for (ext in c("csv", "tsv", "json", "md", "html", "pdf")) {
  f <- file.path(dir, paste0("placements.", ext))
  yoy_write(y, f, title = "Placements by gender")
  cat(sprintf("%-5s %6d bytes\n", ext, file.size(f)))
}
```

The delimited writers lead with the settings as comments, and carry the
`flag` column, so a withheld percentage stays marked as withheld
wherever it lands:

```{r}
cat(yoy_csv(y, NULL, digits = 1L))
```

Markdown, for a report:

```{r}
cat(yoy_markdown(yoy(gap, value = n, period = year), NULL))
```

The HTML is a single self-contained file: it makes no network request, so
the page renders later exactly as it rendered when the capsule was
sealed. It defines its palette as custom properties and carries a dark
variant. The PDF is drawn on R's own device and paginates rather than
truncating, so neither needs a package beyond base R.

```{r}
yoy_palettes()
```

`"safe"` is a blue/orange pair that survives both common forms of colour
blindness; `"mono"` emits no colour at all, for print.

```{r, include = FALSE}
unlink(file.path(dir, paste0("placements.",
                             c("csv", "tsv", "json", "md", "html", "pdf"))))
```
