# Per-Tile Quality (Uncertainty) API


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

Every marking in the v3.1 catalog is a *cluster* of citizen-science
markings averaged into one object. The catalog records, for each, the
standard deviations of the markings that were averaged — `x_std`,
`y_std`, `angle_std`, plus `distance_std`/`spread_std` (fans) or
`radius1_std`/`radius2_std` (blotches) — together with the vote count
`n_votes`. These are the raw material for a per-tile quality measure.

A raw standard deviation conflates **two different causes of
unreliability**: markers genuinely *disagreeing* (scattered votes) and
*weak support* (few votes). This API keeps the two on separate,
never-mixed axes. Vote counts never divide the scatter numbers. In
particular, a **standard error of the mean** — SEM, the standard
deviation divided by √n — shrinks as votes accumulate, so using it would
quietly fold vote *support* back into the *scatter* axis and re-mix the
two things we are trying to keep apart. Reporting raw scatter instead
keeps *contested* tiles (many votes, real disagreement) visible rather
than averaging them away.

``` python
import numpy as np, pandas as pd
from p4tools import io, stats
pd.set_option('display.max_columns', None)
```

## Layer 1 — per-marking enrichment

[`add_uncertainty_columns`](https://michaelaye.github.io/p4tools/stats.html#add_uncertainty_columns)
infers the kind (fan vs blotch) from the columns and adds four derived
columns:

<table>
<colgroup>
<col style="width: 33%" />
<col style="width: 33%" />
<col style="width: 33%" />
</colgroup>
<thead>
<tr>
<th>column</th>
<th>unit</th>
<th>meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>pos_std</code></td>
<td>px</td>
<td><code>hypot(x_std, y_std)</code> — positional scatter of the
cluster</td>
</tr>
<tr>
<td><code>size_cv</code></td>
<td>—</td>
<td>mean <em>relative</em> std (a coefficient of variation): fans use
<code>distance</code>/<code>spread</code>, blotches
<code>radius_1</code>/<code>radius_2</code></td>
</tr>
<tr>
<td><code>angle_usable</code></td>
<td>bool</td>
<td>always True for fans; False for near-circular blotches
(<code>radius_2/radius_1 &gt; 0.8</code>), where orientation is
ill-defined</td>
</tr>
<tr>
<td><code>scatter_ok</code></td>
<td>bool</td>
<td><code>n_votes &gt;= min_votes</code> — a std from a handful of votes
is itself noise</td>
</tr>
</tbody>
</table>

``` python
fu = stats.add_uncertainty_columns(io.get_fan_catalog("v3.1"))
fu[["tile_id","n_votes","pos_std","size_cv","angle_usable","scatter_ok"]].head()
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }
&#10;    .dataframe tbody tr th {
        vertical-align: top;
    }
&#10;    .dataframe thead th {
        text-align: right;
    }
</style>

<table class="dataframe" data-quarto-postprocess="true" data-border="1">
<thead>
<tr style="text-align: right;">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">tile_id</th>
<th data-quarto-table-cell-role="th">n_votes</th>
<th data-quarto-table-cell-role="th">pos_std</th>
<th data-quarto-table-cell-role="th">size_cv</th>
<th data-quarto-table-cell-role="th">angle_usable</th>
<th data-quarto-table-cell-role="th">scatter_ok</th>
</tr>
</thead>
<tbody>
<tr>
<td data-quarto-table-cell-role="th">0</td>
<td>APF00006nk</td>
<td>11.0</td>
<td>5.519993</td>
<td>0.249842</td>
<td>True</td>
<td>True</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">1</td>
<td>APF00006nk</td>
<td>10.0</td>
<td>4.979427</td>
<td>0.305487</td>
<td>True</td>
<td>True</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">2</td>
<td>APF00006nk</td>
<td>10.0</td>
<td>3.649387</td>
<td>0.337209</td>
<td>True</td>
<td>True</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">3</td>
<td>APF00006nk</td>
<td>8.0</td>
<td>4.086334</td>
<td>0.226463</td>
<td>True</td>
<td>True</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">4</td>
<td>APF00006nk</td>
<td>8.0</td>
<td>5.117829</td>
<td>0.247728</td>
<td>True</td>
<td>True</td>
</tr>
</tbody>
</table>

</div>

For blotches, `angle_usable` drops the near-circular ones whose fitted
orientation is meaningless:

``` python
bu = stats.add_uncertainty_columns(io.get_blotch_catalog("v3.1"))
bu.angle_usable.value_counts()
```

    angle_usable
    True     240905
    False    186499
    Name: count, dtype: int64

## Layer 2 — per-tile aggregation

[`tile_quality`](https://michaelaye.github.io/p4tools/stats.html#tile_quality)
returns one row per `tile_id`, with columns organised into named
**groups**:

- **support** — *how much evidence backs this tile*: `n_markings`,
  `n_fans`, `n_blotches`, `votes_median`, `votes_min`, `votes_total`.
  Pure vote axis.
- **scatter** — *how much the markers disagreed*: `angle_scatter`,
  `pos_scatter`, `size_scatter`, computed **only from markings that pass
  the vote gate** (`scatter_ok`, i.e. `n_votes >= min_votes`), so weak
  support can never leak in as apparent disagreement.
  `n_scatter_markings` reports how many survived; scatter is `NaN` if
  none did.
- **ranks** — each raw metric as its cap-wide percentile position
  (0–100; 50 = median tile). `scatter_rank` is the **median** of the
  three scatter metrics’ individual ranks, and `support_rank` the same
  on the vote axis — so incommensurate units (deg, px, —) become
  comparable and combinable.

**Why ranks, concretely.** A tile with `angle_scatter` 15° (p80),
`pos_scatter` 3 px (p40), `size_scatter` 0.10 (p55) gets
`scatter_rank = median(80, 40, 55) = 55` — slightly worse than the
median tile. Another at (p20, p95, p92) gets `median = 92` — very
scattered, driven by position and size while its angles agree. Raw units
could not be combined this way (`(15 + 3 + 0.10)/3` is meaningless).

``` python
tq = stats.tile_quality("both", version="v3.1")
tq.head()
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }
&#10;    .dataframe tbody tr th {
        vertical-align: top;
    }
&#10;    .dataframe thead th {
        text-align: right;
    }
</style>

<table class="dataframe" data-quarto-postprocess="true" data-border="1">
<thead>
<tr style="text-align: right;">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">n_markings</th>
<th data-quarto-table-cell-role="th">n_fans</th>
<th data-quarto-table-cell-role="th">n_blotches</th>
<th data-quarto-table-cell-role="th">votes_median</th>
<th data-quarto-table-cell-role="th">votes_min</th>
<th data-quarto-table-cell-role="th">votes_total</th>
<th data-quarto-table-cell-role="th">angle_scatter</th>
<th data-quarto-table-cell-role="th">pos_scatter</th>
<th data-quarto-table-cell-role="th">size_scatter</th>
<th data-quarto-table-cell-role="th">n_scatter_markings</th>
<th data-quarto-table-cell-role="th">scatter_rank</th>
<th data-quarto-table-cell-role="th">support_rank</th>
</tr>
<tr>
<th data-quarto-table-cell-role="th">tile_id</th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
</tr>
</thead>
<tbody>
<tr>
<td data-quarto-table-cell-role="th">APF0000001</td>
<td>4</td>
<td>3</td>
<td>1</td>
<td>17.0</td>
<td>7.0</td>
<td>60.0</td>
<td>5.329304</td>
<td>4.432020</td>
<td>0.306949</td>
<td>4</td>
<td>30.809548</td>
<td>57.756535</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">APF0000002</td>
<td>2</td>
<td>0</td>
<td>2</td>
<td>27.0</td>
<td>11.0</td>
<td>54.0</td>
<td>23.466506</td>
<td>9.144525</td>
<td>0.406551</td>
<td>2</td>
<td>79.314403</td>
<td>54.660899</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">APF0000004</td>
<td>4</td>
<td>0</td>
<td>4</td>
<td>10.5</td>
<td>5.0</td>
<td>42.0</td>
<td>19.672716</td>
<td>5.126946</td>
<td>0.221274</td>
<td>4</td>
<td>22.280351</td>
<td>47.259435</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">APF0000005</td>
<td>5</td>
<td>0</td>
<td>5</td>
<td>33.0</td>
<td>7.0</td>
<td>143.0</td>
<td>26.156416</td>
<td>6.272975</td>
<td>0.551231</td>
<td>5</td>
<td>83.691334</td>
<td>81.310665</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">APF0000006</td>
<td>11</td>
<td>4</td>
<td>7</td>
<td>21.0</td>
<td>16.0</td>
<td>246.0</td>
<td>22.180734</td>
<td>5.149757</td>
<td>0.345502</td>
<td>11</td>
<td>60.195479</td>
<td>91.661240</td>
</tr>
</tbody>
</table>

</div>

Tiles where no marking clears the vote gate have `NaN` scatter (and
`scatter_rank`), reported honestly rather than imputed:

``` python
tq[["n_markings","n_scatter_markings","scatter_rank","support_rank"]].describe().round(2)
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }
&#10;    .dataframe tbody tr th {
        vertical-align: top;
    }
&#10;    .dataframe thead th {
        text-align: right;
    }
</style>

<table class="dataframe" data-quarto-postprocess="true" data-border="1">
<thead>
<tr style="text-align: right;">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">n_markings</th>
<th data-quarto-table-cell-role="th">n_scatter_markings</th>
<th data-quarto-table-cell-role="th">scatter_rank</th>
<th data-quarto-table-cell-role="th">support_rank</th>
</tr>
</thead>
<tbody>
<tr>
<td data-quarto-table-cell-role="th">count</td>
<td>64494.00</td>
<td>64494.00</td>
<td>56579.00</td>
<td>64494.00</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">mean</td>
<td>10.74</td>
<td>7.27</td>
<td>48.93</td>
<td>50.35</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">std</td>
<td>17.12</td>
<td>12.76</td>
<td>22.00</td>
<td>27.61</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">min</td>
<td>1.00</td>
<td>0.00</td>
<td>0.10</td>
<td>6.17</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">25%</td>
<td>2.00</td>
<td>1.00</td>
<td>32.53</td>
<td>26.25</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">50%</td>
<td>6.00</td>
<td>3.00</td>
<td>49.12</td>
<td>50.02</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">75%</td>
<td>13.00</td>
<td>9.00</td>
<td>65.19</td>
<td>74.31</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">max</td>
<td>280.00</td>
<td>211.00</td>
<td>99.77</td>
<td>99.98</td>
</tr>
</tbody>
</table>

</div>

## Layer 3 — opt-in classification

This layer is **opt-in**:
[`tile_quality`](https://michaelaye.github.io/p4tools/stats.html#tile_quality)
already gives you the full, continuous picture (the
`support_rank`/`scatter_rank` axes), and you only call
[`classify_tile_quality`](https://michaelaye.github.io/p4tools/stats.html#classify_tile_quality)
if you additionally want discrete labels. It is a separate step because
collapsing two continuous ranks into four buckets *discards information*
and bakes in a threshold choice (where “high” starts, default the 50th
percentile). Many analyses are better served by the raw ranks —
e.g. weighting by `scatter_rank` — so the labels are offered, never
imposed.

[`classify_tile_quality`](https://michaelaye.github.io/p4tools/stats.html#classify_tile_quality)
splits the two rank axes at a percentile threshold (default 50) into
four classes:

<table>
<colgroup>
<col style="width: 25%" />
<col style="width: 25%" />
<col style="width: 25%" />
<col style="width: 25%" />
</colgroup>
<thead>
<tr>
<th><code>quality_class</code></th>
<th>support</th>
<th>scatter</th>
<th>reading</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>consistent</code></td>
<td>high</td>
<td>low</td>
<td>reliable — best QC tier</td>
</tr>
<tr>
<td><code>contested</code></td>
<td>high</td>
<td>high</td>
<td>many votes, <strong>genuine disagreement</strong> — scientifically
interesting; exactly the class a single conflated score would hide</td>
</tr>
<tr>
<td><code>sparse</code></td>
<td>low</td>
<td>low</td>
<td>few votes, but they agree</td>
</tr>
<tr>
<td><code>noisy</code></td>
<td>low</td>
<td>high</td>
<td>least reliable</td>
</tr>
</tbody>
</table>

Tiles with no scatter estimate (`scatter_rank` NaN) are left `<NA>`
rather than guessed.

``` python
cls = stats.classify_tile_quality(tq)
cls.quality_class.value_counts(dropna=False)
```

    quality_class
    consistent    17194
    contested     15294
    noisy         12192
    sparse        11899
    <NA>           7915
    Name: count, dtype: int64

The **contested** tiles — high vote support *and* high disagreement —
are the ones worth a human look:

``` python
cls[cls.quality_class == "contested"].sort_values("support_rank", ascending=False).head()
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }
&#10;    .dataframe tbody tr th {
        vertical-align: top;
    }
&#10;    .dataframe thead th {
        text-align: right;
    }
</style>

<table class="dataframe" data-quarto-postprocess="true" data-border="1">
<thead>
<tr style="text-align: right;">
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th">n_markings</th>
<th data-quarto-table-cell-role="th">n_fans</th>
<th data-quarto-table-cell-role="th">n_blotches</th>
<th data-quarto-table-cell-role="th">votes_median</th>
<th data-quarto-table-cell-role="th">votes_min</th>
<th data-quarto-table-cell-role="th">votes_total</th>
<th data-quarto-table-cell-role="th">angle_scatter</th>
<th data-quarto-table-cell-role="th">pos_scatter</th>
<th data-quarto-table-cell-role="th">size_scatter</th>
<th data-quarto-table-cell-role="th">n_scatter_markings</th>
<th data-quarto-table-cell-role="th">scatter_rank</th>
<th data-quarto-table-cell-role="th">support_rank</th>
<th data-quarto-table-cell-role="th">quality_class</th>
</tr>
<tr>
<th data-quarto-table-cell-role="th">tile_id</th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
<th data-quarto-table-cell-role="th"></th>
</tr>
</thead>
<tbody>
<tr>
<td data-quarto-table-cell-role="th">APF00007kk</td>
<td>179</td>
<td>0</td>
<td>179</td>
<td>9.0</td>
<td>3.0</td>
<td>1624.0</td>
<td>24.614313</td>
<td>5.460791</td>
<td>0.325892</td>
<td>150</td>
<td>53.760229</td>
<td>99.827116</td>
<td>contested</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">APF00007to</td>
<td>160</td>
<td>0</td>
<td>160</td>
<td>5.0</td>
<td>3.0</td>
<td>1119.0</td>
<td>24.034693</td>
<td>5.931912</td>
<td>0.349409</td>
<td>92</td>
<td>61.369059</td>
<td>99.627872</td>
<td>contested</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">APF00007t3</td>
<td>148</td>
<td>0</td>
<td>148</td>
<td>7.0</td>
<td>3.0</td>
<td>1090.0</td>
<td>33.661004</td>
<td>5.174341</td>
<td>0.331405</td>
<td>121</td>
<td>55.626646</td>
<td>99.568952</td>
<td>contested</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">APF00007mk</td>
<td>150</td>
<td>0</td>
<td>150</td>
<td>7.0</td>
<td>3.0</td>
<td>1058.0</td>
<td>32.251943</td>
<td>4.964730</td>
<td>0.317002</td>
<td>104</td>
<td>50.826278</td>
<td>99.567402</td>
<td>contested</td>
</tr>
<tr>
<td data-quarto-table-cell-role="th">APF00007tz</td>
<td>135</td>
<td>0</td>
<td>135</td>
<td>9.0</td>
<td>4.0</td>
<td>1347.0</td>
<td>29.321577</td>
<td>4.536079</td>
<td>0.322249</td>
<td>124</td>
<td>52.574277</td>
<td>99.495302</td>
<td>contested</td>
</tr>
</tbody>
</table>

</div>

## Using it downstream

Because the output is `tile_id`-indexed, it joins straight onto any
tile-level analysis — e.g. to weight or filter coverage numbers by tile
reliability:

``` python
reliable = cls.index[cls.quality_class == "consistent"]
print(f"{len(reliable):,} consistent tiles of {len(cls):,} "
      f"({100*len(reliable)/len(cls):.1f}%)")
# cov = io.get_tile_coords().merge(cls, on="tile_id")  # then subset / weight
```

    17,194 consistent tiles of 64,494 (26.7%)
