Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | 2x 133x 133x 2x 272x 272x 272x 272x 272x 272x 212x 212x 2x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 94x 1x 1x 1x 94x 93x 94x 28x 28x 28x 28x 2x 44x 44x 44x 44x 44x 43x 44x 41x 41x 41x 41x 43x 43x 43x 44x 43x 44x 1334x 1334x 1334x 43x 43x 2x 52x 52x 51x 51x 51x 51x 51x 52x 52x 52x 51x 51x 51x 51x 51x 51x 52x 3328x 3328x 3328x 48896x 48896x 48896x 3328x 3328x 3328x 51x 51x | /**
* The three summaries a distribution can be drawn as.
*
* A histogram, a box and a violin are the same values three ways, and none of
* them is a plot of the data: each is a *claim* about it, made by a rule with a
* parameter. So the rules live here, apart from the drawing, where they can be
* read and checked — a bin width chosen badly is a bimodal distribution that
* looks unimodal, and that is not the kind of decision to leave inside a
* renderer.
*
* Everything here ignores `NaN` rather than treating it as zero, which is the
* distinction the whole app is built on: a bin that was filtered and a bin that
* touches nothing are different facts.
*/
/** Finite values, sorted, which every summary below starts from. */
export function sorted(values: readonly number[]): number[] {
return values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b)
}
/**
* The type-7 quantile — R's default, numpy's default, and the one most readers
* will have computed if they check this against their own analysis.
*/
export function quantile(ascending: readonly number[], p: number): number {
if (ascending.length === 0) return Number.NaN
if (ascending.length === 1) return ascending[0]
const at = (ascending.length - 1) * Math.min(1, Math.max(0, p))
const below = Math.floor(at)
const above = Math.ceil(at)
if (below === above) return ascending[below]
return ascending[below] + (at - below) * (ascending[above] - ascending[below])
}
export interface BoxSummary {
/** The whisker ends: the furthest values still within 1.5 IQR of the box. */
low: number
q1: number
median: number
q3: number
high: number
/** Everything outside the whiskers, drawn as points. */
outliers: number[]
}
/**
* Tukey's box: the quartiles, and whiskers at the furthest *observed* value
* within 1.5 interquartile ranges.
*
* The whiskers stop at data rather than at `q1 - 1.5·IQR` itself, which is what
* makes them a statement about the sample: a whisker drawn to the fence would
* reach past every value there is and say the range is wider than it was
* measured to be.
*/
export function boxSummary(values: readonly number[]): BoxSummary {
const ascending = sorted(values)
if (ascending.length === 0) {
return { low: Number.NaN, q1: Number.NaN, median: Number.NaN, q3: Number.NaN, high: Number.NaN, outliers: [] }
}
const q1 = quantile(ascending, 0.25)
const median = quantile(ascending, 0.5)
const q3 = quantile(ascending, 0.75)
const fence = 1.5 * (q3 - q1)
const floor = q1 - fence
const ceiling = q3 + fence
let low = Number.POSITIVE_INFINITY
let high = Number.NEGATIVE_INFINITY
const outliers: number[] = []
for (const value of ascending) {
if (value < floor || value > ceiling) {
outliers.push(value)
continue
}
if (value < low) low = value
if (value > high) high = value
}
if (!Number.isFinite(low)) low = median
if (!Number.isFinite(high)) high = median
return { low, q1, median, q3, high, outliers }
}
export interface Histogram {
/** `bins + 1` edges, evenly spaced. */
edges: number[]
/** One count per bin; the last bin includes its right edge. */
counts: number[]
}
/**
* A histogram, with the bin count chosen if it is not given.
*
* Freedman–Diaconis when it is not: bin width `2·IQR/n^(1/3)`, which adapts to
* the spread of the middle half rather than to the extremes, so one outlier
* does not flatten the whole picture into two bars. Sturges' rule — which is
* what a naive `log2(n)+1` gives — is famously too coarse for anything with a
* tail, and a contact-frequency distribution is nothing but tail.
*
* Capped, because the bins are drawn: four hundred bars on a figure six hundred
* pixels wide is a solid block.
*/
export function histogram(values: readonly number[], bins = 0): Histogram {
const ascending = sorted(values)
if (ascending.length === 0) return { edges: [], counts: [] }
const lo = ascending[0]
const hi = ascending[ascending.length - 1]
if (lo === hi) return { edges: [lo, lo + 1], counts: [ascending.length] }
let count = Math.round(bins)
if (!(count > 0)) {
const iqr = quantile(ascending, 0.75) - quantile(ascending, 0.25)
const width = iqr > 0 ? (2 * iqr) / Math.cbrt(ascending.length) : 0
count = width > 0 ? Math.ceil((hi - lo) / width) : Math.ceil(Math.sqrt(ascending.length))
}
count = Math.max(1, Math.min(400, count))
const step = (hi - lo) / count
const edges: number[] = []
for (let i = 0; i <= count; i++) edges.push(lo + i * step)
const counts = new Array<number>(count).fill(0)
for (const value of ascending) {
const at = Math.floor((value - lo) / step)
counts[at >= count ? count - 1 : at < 0 ? 0 : at] += 1
}
return { edges, counts }
}
/**
* A violin's outline: a Gaussian kernel density estimate, sampled evenly.
*
* Silverman's rule of thumb for the bandwidth, from the smaller of the standard
* deviation and the IQR over 1.34 — the same default R's `density()` uses, so a
* violin here and a violin drawn from the same numbers in R are the same shape
* rather than two opinions about it.
*
* Returned as density per unit, not normalised to a width: how wide the widest
* violin is drawn is the renderer's decision, and two violins side by side have
* to be scaled together or the comparison is a lie.
*/
export function density(values: readonly number[], points = 64): { at: number[]; y: number[] } {
const ascending = sorted(values)
if (ascending.length < 2) return { at: [], y: [] }
const n = ascending.length
const mean = ascending.reduce((sum, value) => sum + value, 0) / n
const variance = ascending.reduce((sum, value) => sum + (value - mean) ** 2, 0) / (n - 1)
const sd = Math.sqrt(Math.max(0, variance))
const iqr = quantile(ascending, 0.75) - quantile(ascending, 0.25)
const spread = Math.min(sd || Number.POSITIVE_INFINITY, iqr > 0 ? iqr / 1.34 : Number.POSITIVE_INFINITY)
const bandwidth = Number.isFinite(spread) && spread > 0 ? 0.9 * spread * Math.pow(n, -0.2) : 0
if (!(bandwidth > 0)) return { at: [], y: [] }
// Three bandwidths past each end, which is where a Gaussian has given up.
const lo = ascending[0] - 3 * bandwidth
const hi = ascending[n - 1] + 3 * bandwidth
const step = (hi - lo) / Math.max(1, points - 1)
const at: number[] = []
const y: number[] = []
const scale = 1 / (n * bandwidth * Math.sqrt(2 * Math.PI))
for (let i = 0; i < points; i++) {
const x = lo + i * step
let sum = 0
for (const value of ascending) {
const z = (x - value) / bandwidth
sum += Math.exp(-0.5 * z * z)
}
at.push(x)
y.push(sum * scale)
}
return { at, y }
}
|