Skip to content

Commit 532704d

Browse files
vahid-ahmadiclaude
andcommitted
Skip NaN in quantile() and median()
NaN sorts to the end of np.argsort and its weight still counted toward the cumulative distribution, so the inverse-CDF cutoff was pushed upward: MicroSeries([1.0, nan, 3.0], weights=[1, 1, 1]).median() # 3.0 MicroSeries([1.0, 3.0], weights=[1, 1]).median() # 1.0 Dropping a NaN row should give the same answer as never having had it. Add skipna (default True), dropping NaN rows alongside the existing zero-weight filter; skipna=False returns NaN when any value is NaN, matching mean/var/std. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 46cffbd commit 532704d

3 files changed

Lines changed: 59 additions & 4 deletions

File tree

changelog.d/quantile-skipna.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
- bump: minor
2+
changes:
3+
fixed:
4+
- quantile() and median() now skip NaN values by default, so NaN weight no
5+
longer inflates the cumulative distribution and pushes the cutoff up.
6+
added:
7+
- skipna argument on MicroSeries.quantile and MicroSeries.median.

microdf/microseries.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ def corr(self, other, *args, **kwargs):
306306
)
307307
return super().corr(other, *args, **kwargs)
308308

309-
def quantile(self, q: np.array) -> pd.Series:
309+
def quantile(self, q: np.array, skipna: bool = True) -> pd.Series:
310310
"""Calculates weighted quantiles of the MicroSeries.
311311
312312
Uses the inverse CDF method: the q-th quantile is the smallest
@@ -315,13 +315,25 @@ def quantile(self, q: np.array) -> pd.Series:
315315
316316
:param q: Quantile(s) to calculate, must be in [0, 1].
317317
:type q: float or np.array
318+
:param skipna: Exclude NaN values (default True). NaN sorts to the
319+
end of the array, so leaving NaN rows in would let their weight
320+
inflate the cumulative distribution and push the cutoff upward.
321+
If False, NaN is returned whenever any value is NaN.
322+
:type skipna: bool
318323
319324
:return: Weighted quantile value(s).
320325
:rtype: float or pd.Series
321326
"""
322327
values = np.array(self._values)
323328
quantiles = np.atleast_1d(q)
324329
sample_weight = np.array(self.weights)
330+
na_mask = pd.isna(values)
331+
if not skipna and na_mask.any():
332+
return (
333+
np.nan
334+
if np.array(q).shape == ()
335+
else pd.Series(np.full(len(quantiles), np.nan), index=quantiles)
336+
)
325337
assert np.all(quantiles >= 0) and np.all(quantiles <= 1), (
326338
"quantiles should be in [0, 1]"
327339
)
@@ -330,7 +342,10 @@ def quantile(self, q: np.array) -> pd.Series:
330342
# that should have been skipped by the inverse CDF. E.g.
331343
# MicroSeries([10, 20, 30], weights=[0, 1, 1]).quantile(0)
332344
# returned 10 instead of 20.
333-
nonzero = sample_weight > 0
345+
# Drop NaN rows for the same reason: NaN sorts last, so its weight
346+
# would inflate the cumulative distribution and push the cutoff up
347+
# (median of [1, nan, 3] returned 3.0 instead of 1.0).
348+
nonzero = (sample_weight > 0) & ~na_mask
334349
if not nonzero.any():
335350
return (
336351
np.nan
@@ -355,13 +370,15 @@ def quantile(self, q: np.array) -> pd.Series:
355370
return pd.Series(result, index=quantiles)
356371

357372
@scalar_function
358-
def median(self) -> float:
373+
def median(self, skipna: bool = True) -> float:
359374
"""Calculates the weighted median of the MicroSeries.
360375
376+
:param skipna: Exclude NaN values (default True).
377+
:type skipna: bool
361378
:returns: The weighted median of a DataFrame's column.
362379
:rtype: float
363380
"""
364-
return self.quantile(0.5)
381+
return self.quantile(0.5, skipna=skipna)
365382

366383
@scalar_function
367384
def gini(self, negatives: Optional[str] = None) -> float:

microdf/tests/test_microseries_dataframe.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,3 +815,34 @@ def test_rank_ties_share_bucket() -> None:
815815
# existing ``test_rank`` expectations hold.
816816
s = mdf.MicroSeries([1, 2, 3], weights=[4, 5, 6])
817817
np.testing.assert_array_equal(s.rank().values, [4, 9, 15])
818+
819+
820+
def test_quantile_skips_nan():
821+
"""NaN weight must not inflate the cumulative distribution.
822+
823+
Dropping a NaN row should give the same answer as never having had
824+
it: the inverse-CDF quantile of [1, nan, 3] equals that of [1, 3].
825+
"""
826+
with_nan = mdf.MicroSeries([1.0, np.nan, 3.0], weights=[1, 1, 1])
827+
without_nan = mdf.MicroSeries([1.0, 3.0], weights=[1, 1])
828+
assert with_nan.median() == without_nan.median()
829+
assert with_nan.quantile(0.5) == without_nan.quantile(0.5)
830+
831+
q = [0.25, 0.5, 0.75]
832+
np.testing.assert_array_equal(
833+
mdf.MicroSeries([1.0, np.nan, 3.0, 5.0], weights=[1, 1, 1, 1]).quantile(q),
834+
mdf.MicroSeries([1.0, 3.0, 5.0], weights=[1, 1, 1]).quantile(q),
835+
)
836+
837+
838+
def test_quantile_skipna_false_propagates_nan():
839+
"""Skipna=False returns NaN when any value is NaN, like mean/var."""
840+
s = mdf.MicroSeries([1.0, np.nan, 3.0], weights=[1, 1, 1])
841+
assert np.isnan(s.quantile(0.5, skipna=False))
842+
assert np.isnan(s.median(skipna=False))
843+
assert s.quantile([0.25, 0.75], skipna=False).isna().all()
844+
845+
846+
def test_quantile_all_nan_returns_nan():
847+
s = mdf.MicroSeries([np.nan, np.nan], weights=[1, 1])
848+
assert np.isnan(s.median())

0 commit comments

Comments
 (0)