You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
polars stats: NaN in a float column reaches the histogram percentiles (nan tail label, no NA bar) #1080
Polars keeps NaN distinct from null. pl_histogram_series (pl_stats_v2.py:124) drops nulls and, since #1069, infinities, but not NaN, so NaN rows reach Series.quantile, which sorts NaN above every number. With 5% NaN the 99th percentile is NaN and the high tail is labelled nan–94. With 90% NaN the 1st percentile is the 10th smallest number, so the low tail (0–10) holds about a tenth of the numbers. null_count (pl_stats_v2.py:81) doesn't count NaN, so nan_per is 0 and there is no NA bar, and NaN is counted as a distinct value.
A polars float column gets NaN from 0.0 / 0.0 or from a parquet file that stores NaN.
Suggested fix
Filter NaN with the infinities in pl_histogram_series. Whether NaN counts toward nan_per is the same question #1076 raises for xorq, so it is worth deciding once for the three backends (pandas counts NaN as null).
Context
Found while probing histogram edge cases after #1069. Reproduces on main at e88ace9.
Probe script for this issue. Save it as probe_1080.py in a buckaroo checkout and run uv run python probe_1080.py. The output below is from main at e88ace9. Each case prints the histogram from pandas (pd), polars (pl) and xorq (xo): the bar kinds, the sum of the population bars, the sum of the categorical bars and the NA bar, then each bar as name:kind=value, and below that the histogram_bins range, min, max, nan_per and distinct_count.
probe_1080.py
"""Histogram probe: runs columns through the pandas, polars and xorq stat pipelines and prints each histogram.Each line is [bar kinds, population sum, categorical sum, NA, duplicate names] then the bars as name:kind=value."""importloggingimportwarningsfromcollectionsimportCounterlogging.disable(logging.CRITICAL)
warnings.filterwarnings('ignore')
importnumpyasnpimportpandasaspdimportpolarsasplimportpyarrowaspaimportxorq.apiasxofrombuckaroo.customizations.pd_stats_v2importPD_ANALYSIS_V2frombuckaroo.customizations.pl_stats_v2importPL_ANALYSIS_V2frombuckaroo.customizations.xorq_stats_v2importXORQ_STATS_V2frombuckaroo.pluggable_analysis_framework.stat_pipelineimportStatPipelinefrombuckaroo.pluggable_analysis_framework.xorq_stat_pipelineimportXorqStatPipelinerng=np.random.default_rng(0)
defsummarize(h):
ifnotisinstance(h, list):
returnf'NOT-A-LIST {type(h).__name__}'iflen(h) ==0:
return'EMPTY'kinds=Counter(kforbinhforkinbifk!='name')
parts= []
forbinh:
k=next((kforkinbifk!='name'), '?')
v=b.get(k)
parts.append(f"{b['name']}:{k[:3]}={v:g}"ifisinstance(v, (int, float, np.number)) elsef"{b['name']}:{k}={v}")
dups= [nforn, cinCounter(b['name'] forbinh).items() ifc>1]
pop=sum(b.get('population', 0) forbinh)
cat=sum(b.get(k, 0) forbinhforkin ('cat_pop', 'unique', 'longtail'))
na=sum(b.get('NA', 0) forbinh)
returnf"[{dict(kinds)} pop={pop:g} cat={cat:g} NA={na:g}{' DUPNAMES='+str(dups) ifdupselse''}] "+' | '.join(parts)
defbins_s(b):
ifnotb:
return'bins=[]'arr=np.asarray(b, dtype=float)
returnf'bins={len(b)} [{arr[0]:.6g}..{arr[-1]:.6g}]'+ (''ifnp.isfinite(arr).all() else' NONFINITE')
defrun(backend, values, pd_dtype=None, pl_dtype=None, pa_type=None):
ifbackend=='pd':
sd, errs=StatPipeline(PD_ANALYSIS_V2, unit_test=False).process_df(pd.DataFrame({'a': pd.Series(values, dtype=pd_dtype)}))
elifbackend=='pl':
sd, errs=StatPipeline(PL_ANALYSIS_V2, unit_test=False).process_df(pl.DataFrame({'a': pl.Series('a', values, dtype=pl_dtype, strict=False)}))
else:
table=xo.memtable(pa.table({'a': pa.array(values, type=pa_type)}), name='t')
sd, errs=XorqStatPipeline(XORQ_STATS_V2, unit_test=False).process_table(table)
returnsd.get('a', {}), sorted({f'{e.stat_key}:{type(e.error).__name__}'foreinerrs})
defcase(name, values, pd_dtype=None, pl_dtype=None, pa_type=None, backends=('pd', 'pl', 'xo')):
print(f'\n### {name}')
forbinbackends:
try:
c, errs=run(b, values, pd_dtype, pl_dtype, pa_type)
exceptExceptionase:
print(f' {b}: RAISED {type(e).__name__}: {str(e)[:150]}')
continueprint(f" {b}: {summarize(c.get('histogram'))}")
print(f" {bins_s(c.get('histogram_bins'))} min={c.get('min')!r:.30} max={c.get('max')!r:.30} "f"nan_per={c.get('nan_per')} distinct={c.get('distinct_count')}"+ (f' ERRS={errs}'iferrselse''))
# polars keeps NaN distinct from null; pandas treats NaN as nullcase('5% NaN', [float(i) foriinrange(95)] + [float('nan')] *5, backends=('pd', 'pl'))
case('90% NaN', [float('nan')] *900+ [float(i) foriinrange(100)], backends=('pd', 'pl'))
case('50% NaN + 6 distinct', [float('nan')] *600+ [float(i) foriinrange(6)] *100, backends=('pd', 'pl'))
case('NaN beside an infinity (since #1069 the infinity is filtered, the NaN is not)', [float(i) foriinrange(95)] + [float('nan')] *3+ [float('inf')] *2, backends=('pd', 'pl'))
Activity
paddymul commentedon Oct 7, 2026
Probe script for this issue. Save it as
probe_1080.pyin a buckaroo checkout and runuv run python probe_1080.py. The output below is from main at e88ace9. Each case prints the histogram from pandas (pd), polars (pl) and xorq (xo): the bar kinds, the sum of thepopulationbars, the sum of the categorical bars and the NA bar, then each bar asname:kind=value, and below that thehistogram_binsrange,min,max,nan_peranddistinct_count.probe_1080.py
Output at e88ace9