fix: null handling and numeric correctness in statistics, metrics and parsing - #214
fix: null handling and numeric correctness in statistics, metrics and parsing#214skymanbp wants to merge 6 commits into
Conversation
Stats read the raw payload of nullable columns, so the sentinel stored at null slots entered every result (a zero for unboxed columns, an error thunk for boxed ones): - mean/median/percentile/genericPercentile/stddev/skewness/variance/ IQR/sum now drop null slots via a shared dropNulls view. - correlation does listwise deletion over both columns. - valueCounts/valueProportions (and so frequencies) no longer count the sentinel as a category. - Maybe-typed views are untouched: their nulls are real values.
- Int mean/median widen per element instead of wrapping at 2^63. - correlation uses the centered two-pass form, clamped: the one-pass form returned |r| > 1, NaN, or a flipped sign on offset data. - skewness computes population g1, the formula the docs define; the old factor was off by sqrt((n-1)/n). - variance of n < 2 is NaN (scatter kernels included), not a fake 0 that made singleton groups look constant. - mse/mae/r2/accuracy/logLoss average over compared pairs and refuse to score zero predictions; meanSquaredError guards length mismatch. - readInt rejects overflow instead of wrapping. - Double parsing is correctly rounded: exact byte-level reference (clamped exponents), Clinger fast path, Infinity round-trips.
| let var = if c < 2 then 0 else mm / fromIntegral (c - 1) | ||
| -- Sample variance is undefined at n = 1: NaN, matching | ||
| -- 'computeVariance'. | ||
| let var = if c < 2 then 0 / 0 else mm / fromIntegral (c - 1) |
There was a problem hiding this comment.
Hmmm. @daikonradish does it make sense to stick a NaN here or fail instead. I think the right thing is to make it optional but the ergonomics of dealing with Maybe make me question putting it in the happy path.
There was a problem hiding this comment.
It's the same code the groupBy interpreter path uses... so Ig throwing there means one singleton group kills the whole aggregation (and the kernels would have to match)? NaN keeps the singleton visible without killing the query, and matches pandas/numpy (sample variance of n=1 -> NaN). Can simply switch all three to throw... (if u want it to fail loud)
| App m _ | Just HRefl <- eqTypeRep m (typeRep @Maybe) -> xs | ||
| _ -> VG.backpermute xs keep | ||
| where | ||
| keep = VG.fromList [i | i <- [0 .. VG.length xs - 1], bitmapTestBit bm i] |
There was a problem hiding this comment.
keep = VG.filter (bitmapTestBit bm) (VG.enumFromN 0 (VG.length xs))Will fuse and probably avoid the intermediate allocation. Since fromList doesn't know the final number of elements it grows its buffer dynamically which also could have a perf cost.
There was a problem hiding this comment.
Went one further. DropNulls takes the column now and ifilters the payload directly, so there's no index vector at all.
| columnBitmap (PackedText bm _) = bm | ||
| columnBitmap (MergedColumn _ _) = Nothing | ||
|
|
||
| {- | Drop the null slots of a payload-typed view of a nullable column: those |
There was a problem hiding this comment.
-- | Drops the null values in a nullable column.
| mse preds truth | ||
| | VU.null truth = 0 | ||
| -- No predictions is not a perfect score. | ||
| | n == 0 = 0 / 0 |
There was a problem hiding this comment.
Same as above. Nothing or throwing would make sense here.
There was a problem hiding this comment.
Throws now (EmptyDataSetException). Also dropped the old empty-truth = 0 guard. unless if you want empty-truth = 0 back.
| mae preds truth | ||
| | VU.null truth = 0 | ||
| | otherwise = VU.sum (VU.zipWith (\p t -> abs (p - t)) preds truth) / n2 truth | ||
| | n == 0 = 0 / 0 |
| in | ||
| Just $ squareDiff / fromIntegral (max (VU.length target) (VU.length prediction)) | ||
| meanSquaredError target prediction | ||
| | VU.length target /= VU.length prediction = Nothing |
| (Columnable a) => Expr a -> DataFrame -> Either DataFrameException (V.Vector a) | ||
| columnAsVectorNonNull expr df = case expr of | ||
| Col name -> case getColumn name df of | ||
| Just col -> withColumnName name (dropNulls (columnBitmap col) <$> toVector col) |
There was a problem hiding this comment.
Now seeing how it's used this dropNulls function relies on getting the correspondence between the bitmap and the contents right even after they have been split apart. It should rather be dropNulls :: Column -> Column and the call site should be:
withColumnName name (toVector (dropNulls col))There was a problem hiding this comment.
Cool. DropNulls is Column -> Column. Tho one wrinkle is that a Maybe-typed view has to keep its Nothings, so the type-generic entry points (genericPercentile, valueCounts) go through a small dropNullsExceptMaybe guard; the numeric ones use dropNulls directly.
| columnAsVectorNonNull :: | ||
| forall a. | ||
| (Columnable a) => Expr a -> DataFrame -> Either DataFrameException (V.Vector a) | ||
| columnAsVectorNonNull expr df = case expr of |
There was a problem hiding this comment.
It's not obvious from this function name that it does a non trivial operation (filter). All the other functions of this form do a cast at best and are clear about how they cast. Someone could run the function on two columns and get two different lengths then be shocked when zipWith doesn't work or maybe some stats functions don't throw. I don't have many good ideas here but let's rename this and I'm not sure if should be in the public API.
There was a problem hiding this comment.
Renamed to nonNullElements. It was already private (not in the export list). Not sure tho if you'd rather a different name...
| w2d w | ||
| | w <= 9007199254740991 = fromIntegral (fromIntegral w :: Int) | ||
| | otherwise = fromInteger (toInteger w) | ||
| w2d w = fromIntegral (fromIntegral w :: Int) |
There was a problem hiding this comment.
I'm not sure why this is necessary. Since these parsing functions are in the fast path they also deserver their own issue + PR. Haskell is really perf sensetive and I want to ensure we understand changes like this and their tradeoffs.
There was a problem hiding this comment.
Fair. Just pulled out all the parsing changes. Thought both readers were approximate (81% of a 200k show/read sample comes back 1-7 ULP off, subnormals parse as 0, and the largest finite double reads as Infinity). Raised issue #218...
| | T.length t <= 18 = case signed decimal t of | ||
| Right (value, "") -> Just value | ||
| _ -> Nothing | ||
| | otherwise = case signed decimal t :: Either String (Integer, T.Text) of |
There was a problem hiding this comment.
Integer is also way more expensive.
There was a problem hiding this comment.
Revrrted. although I think fwiw the Integer path only kicked in for fields longer than 18 chars? like the common path was untouched, but it'll come back with numbers in the parsing PR.
|
Trimmed this down to the null-handling half per review:
Full suite green locally. |
| {- | 'dropNulls', unless the view type @a@ is @Maybe@-headed: a @Maybe@-typed | ||
| view encodes the nulls as values, so the column passes through untouched. | ||
| -} | ||
| dropNullsExceptMaybe :: forall a. (Typeable a) => Column -> Column |
There was a problem hiding this comment.
This is confusing. Why doesn't drop nulls handle this case. That would be a good thing to put in the comment. Let me read the calm site.
| valueProportions expr df | ||
| | null df = throw (EmptyDataSetException "valueCounts") | ||
| | otherwise = case columnAsVector expr df of | ||
| | otherwise = case nonNullElements expr df of |
There was a problem hiding this comment.
This doesn't make sense to me. A column with a null bitmap here should render as a Maybe a since that's how the user should assume when computing. Our null bitmap is an implementation detail that shouldn't drift from the DSL which assumes all Maybes are null bitmapped. And that's gated at column construction. So this nonNullElements function is both superfluous and maybe a little harmful.
There was a problem hiding this comment.
Fair. Helper dropped. valueCounts/valueProportions are now back on columnAsVector so a nullable column reads as Maybe a. Although, I think toVector @int on a bitmapped Int column takes the raw branch and ignores the bitmap. Not sure if you want an issue for it?
| | n < 2 = 0 -- or error "variance of <2 samples" | ||
| | n == 0 = throw $ EmptyDataSetException "variance" | ||
| -- Sample variance is undefined at n = 1: NaN, not a spurious 0. | ||
| | n < 2 = 0 / 0 |
There was a problem hiding this comment.
Cool. Maybe to make this PR easier to reason about please add specific examples or behaviour changes in the PR description. Sort of like how a front end engineer adds screenshots to their PRs. A clear before and after of failure modes and their new fixes - they could even be copies of the test examples but they should be something like:
Before:
$ ./scripts/repl.sh
ghci> :script dataframe.ghci
dataframe> df <- D.readCsv "./data/housing.csv"
dataframe> -- the operationThen after would paste a similar thing.
| computeSkewness (SkewAcc n _ m2 m3) | ||
| | n < 3 = 0 -- or error "skewness of <3 samples" | ||
| | otherwise = (sqrt (fromIntegral n - 1) * m3) / sqrt (m2 ^ (3 :: Int)) | ||
| -- raw sums: g1 = sqrt n * m3 / m2^(3/2) |
There was a problem hiding this comment.
This comment repeats the line below.
The stat entry points read the raw payload of nullable columns, so the
sentinel stored at null slots entered every result: mean of
[10, null, 20] was 10.0, and on boxed columns sum/frequencies threw the
internal fromMaybeVec error at the user.
drop null slots through dropNulls :: Column -> Column; correlation does
listwise deletion; valueCounts (and so frequencies) no longer counts the
sentinel as a category. Maybe-typed views are untouched: their Nothings
are real values.
constant one; now NaN, kernels included (throw vs NaN open in review).
docs define) nor the sample form. Now g1, matching scipy.stats.skew.
truncated to the shorter vector, and scored zero predictions as perfect;
they now average over compared pairs and throw on none.
The Int mean/median, correlation and double-parsing changes that were here
before are reverted per review and filed as #216/#217/#218 to come back as
separate PRs with benchmarks.
Full suite green locally.
Before / after
Before:
After: