The mean of nothing¶
A one-line function, the kind every analytics codebase has, and a question its tests never ask: what does it do when there is nothing to average? This page follows mathema from the first claim to the fix, showing the record at each step.
import pandas as pd
def mean_return(xs: pd.Series) -> float:
"""The mean of a series of returns."""
return float(xs.mean())
The series it will meet, slot by slot:
[0.02, NaN, -0.01] slot 1 is a hole (member nan): pandas skips it, mean 0.005
[NaN, None] every slot a hole (members nan, null): pandas gives NaN
[<NA>] every slot a hole (member NA): xs.mean() is pd.NA
[] no slots at all: empty, not missing
The claim, and what pandas promised¶
A mean of returns in [-1, 1] lies in [-1, 1]:
import mathema
from returns import mean_return
value = mathema.claim("for xs in [-1, 1]^n, -1 <= f(xs) <= 1", name="bounded")
print(mathema.check(mean_return, claims=[value]))
mathema.Record(mean_return) · source, no side effects · form cb973acd88fd
bounded for xs in ([-1.0, 1.0] | {missing})^n : float, -1 <= f(xs) <= 1 falsified at xs = []
holds computation for xs in ([-1.0, 1.0])^n : float, -1 <= f(xs) <= 1 445 entries across 99 draws, sizes (1, 1) to (8, 1)
falsified policy f([]) f(xs) returns nan at xs = []: no value for no data, and no empty policy is stated
possible fixes:
(i) if nan for no data is intended, state: mean_return([]) in {missing}
(ii) guard the empty input at entry
proven policy f([..., missing, ...]) assuming count(xs) >= 1 drops, from pandas.Series.mean's own policy row, which f calls
falsified policy f([..., missing, ...]) assuming count(xs) == 0 raises TypeError, where the word is propagates (from pandas.Series.mean's own policy row, which f calls)
possible fixes:
(i) exclude missing
(ii) handle missing at entry
Read it from the top. The headline is the claim as mathema resolved it:
a float slot may be missing, so [-1, 1]^n became
([-1.0, 1.0] | {missing})^n. The computation line holds on every
series of values it ran: a mean of numbers in [-1, 1] stays there.
The f([]) line is the empty series: its mean is nan, which counts
as no value until a policy for the empty input is stated, and that is
the headline's witness (the last section returns to it).
The other two policy lines say what mean_return does with a slot
that is not there. A pandas Series slot can be nan, null (a None
element) or NA, and mathema put each one in. mean_return makes one
library call, and pandas' own policy rows (bundled with mathema) say
what Series.mean does: it drops holes while values remain, and gives a
hole back when none do. Each line names its case after assuming. The
first, a series with a value left (count(xs) >= 1), is that first row,
proven. The second, a series with none (count(xs) == 0), is the second
row, and it is falsified: at
xs = [NA], a series whose only slot is pandas' NA, xs.mean() is
pd.NA and float(pd.NA) raises TypeError, where pandas' row says a
hole comes back.
The gate: is it missing-safe?¶
is_missing_safe(f) asks whether every missing member has a policy the
code follows:
print(mathema.check(mean_return, claims=[value, mathema.claim("is_missing_safe(f)")]))
mathema.Record(mean_return) · source, no side effects · form cb973acd88fd
bounded for xs in ([-1.0, 1.0] | {missing})^n : float, -1 <= f(xs) <= 1 falsified at xs = []
holds computation for xs in ([-1.0, 1.0])^n : float, -1 <= f(xs) <= 1 445 entries across 99 draws, sizes (1, 1) to (8, 1)
falsified policy f([]) f(xs) returns nan at xs = []: no value for no data, and no empty policy is stated
possible fixes:
(i) if nan for no data is intended, state: mean_return([]) in {missing}
(ii) guard the empty input at entry
proven policy f([..., missing, ...]) assuming count(xs) >= 1 drops, from pandas.Series.mean's own policy row, which f calls
falsified policy f([..., missing, ...]) assuming count(xs) == 0 raises TypeError, where the word is propagates (from pandas.Series.mean's own policy row, which f calls)
possible fixes:
(i) exclude missing
(ii) handle missing at entry
falsified is_missing_safe[f]: is_missing_safe(f)
xs (pandas.Series): nan and null follow pandas.Series.mean's own policy row (drops when values remain, propagates when every slot is missing); NA does not: f raises TypeError when every slot is NA
counterexample xs = [NA]: f raised TypeError
(i) if the raise is intended, state: assuming count(xs) == 0, missing(f, xs, NA) raises(TypeError)
(ii) if not, change f
The value claim's block repeats above it, since the gate is checked
beside it. nan and null follow pandas' rows. NA does not, and no
claim says it may raise, so the gate is falsified with the call that
shows it, and with the claim that would state the raise if it is what
you meant.
And with no slots at all?¶
An empty series has no slots, so no holes; it is a question of its own,
is_empty_safe(xs), and mathema builds the empty value as the
parameter's own runtime type, an empty float Series:
print(mathema.check(mean_return, claims=[value, mathema.claim("is_empty_safe(xs)")]))
mathema.Record(mean_return) · source, no side effects · form cb973acd88fd
bounded for xs in ([-1.0, 1.0] | {missing})^n : float, -1 <= f(xs) <= 1 falsified at xs = []
holds computation for xs in ([-1.0, 1.0])^n : float, -1 <= f(xs) <= 1 445 entries across 99 draws, sizes (1, 1) to (8, 1)
falsified policy f([]) f(xs) returns nan at xs = []: no value for no data, and no empty policy is stated
possible fixes:
(i) if nan for no data is intended, state: mean_return([]) in {missing}
(ii) guard the empty input at entry
proven policy f([..., missing, ...]) assuming count(xs) >= 1 drops, from pandas.Series.mean's own policy row, which f calls
falsified policy f([..., missing, ...]) assuming count(xs) == 0 raises TypeError, where the word is propagates (from pandas.Series.mean's own policy row, which f calls)
possible fixes:
(i) exclude missing
(ii) handle missing at entry
falsified is_empty_safe[xs]: is_empty_safe(xs)
counterexample xs = [] (an empty float Series): f returned nan for the empty input; raise, or return a value
The mean of nothing is nan in pandas, as in numpy; statistics.mean
raises, and polars gives None. A nan handed back for the empty input
looks like a number to the caller and poisons whatever it touches next.
The fix¶
The function has no mean to give when no value is there, so it says so, once, for the empty series and the all-missing one alike:
import pandas as pd
def mean_return(xs: pd.Series) -> float:
"""The mean of a series of returns."""
if xs.count() == 0:
raise ValueError("no returns to average")
return float(xs.mean())
That raise is a choice, so it is stated as the policy for the calls with no value slot, beside the row pandas already gives for the rest:
import returns_fixed
print(mathema.check(returns_fixed.mean_return, claims=[
value,
mathema.claim("assuming count(xs) == 0, missing(f, xs) raises(ValueError)",
name="no_values"),
mathema.claim("is_missing_safe(f)"),
mathema.claim("is_empty_safe(xs)"),
]))
mathema.Record(mean_return) · source, no side effects · form 9671b357ded6
bounded for xs in ([-1.0, 1.0] | {missing})^n : float, -1 <= f(xs) <= 1 holds
holds computation for xs in ([-1.0, 1.0])^n : float, -1 <= f(xs) <= 1 531 entries across 124 draws, sizes (1, 1) to (8, 1)
holds policy f([])
proven policy f([..., missing, ...]) assuming count(xs) >= 1 drops, from pandas.Series.mean's own policy row, which f calls
proven is_empty_safe[xs]: is_empty_safe(xs)
holds no_values: assuming count(xs) == 0, missing(f, xs) raises(ValueError) [stated; confirmed on the 124 draws of bounded]
proven is_missing_safe[f]: is_missing_safe(f)
xs (pandas.Series): nan, null and NA drop when values remain, from pandas.Series.mean's own policy row; raises ValueError when every slot is missing, stated
Every member now has a policy the code follows, from pandas' row where
values remain and from the stated one where none do, so the gate is
proven; the empty series meets the guard, a deliberate rejection, so
is_empty_safe is proven too. The value claim holds, and its headline
with it: the policy lines under it are the empty series, which meets
the guard, and pandas' row for a series with values in it, and the
stated no_values covers the rest.
See missing values for the two kinds, the five behaviours and the policy rows in general.