# Haskell for Data Analysis This guide ports and extends Wes McKinney's [Python for Data Analysis](https://wesmckinney.com/book/). Examples and organisation are drawn from there. No prior Haskell knowledge is assumed. ```haskell -- cabal: build-depends: dataframe, text, granite -- cabal: default-extensions: TemplateHaskell, TypeApplications, OverloadedStrings, DataKinds :set -package granite :set -package dataframe import qualified DataFrame as D import qualified DataFrame.Functions as F import qualified Data.Text.IO as TIO import qualified Data.Text as T import Data.Text (Text) import Granite.Svg import DataFrame.Operators import DataFrame.Operations.Merge () ``` > --- ## Chapter 1: Getting Started ### What is a dataframe? A DataFrame is like a spreadsheet or a table — it organises data into rows and columns. * Each **column** has a name (like "Name", "Age", or "Price") and holds the same kind of data (numbers, text, dates…). * Each **row** is one record — a person, a product, a day of sales. | Name | Age | City | |-------|-----|-----------| | Alice | 30 | New York | | Bob | 25 | San Diego | | Cara | 35 | Austin | That is essentially a DataFrame. DataFrames make it easy to look at your data, filter or sort it, do maths on it, and clean it up. They are the bread-and-butter tool for data scientists and analysts. This guide shows how to use them in Haskell. ### Why Haskell? * Types catch many bugs before you even run the code. * Pipelines are easy to write and read. * The GHC compiler optimises aggressively — Haskell code is fast. * The syntax is more approachable than other compiled languages' dataframe libraries. ### Installing the tooling Check the [README](https://github.com/mchav/dataframe?tab=readme-ov-file#installing) for installation instructions. --- ## Chapter 2: Getting Your Data In Data enters a program in one of two ways: you type it in by hand, or you read it from a file. ### Entering data manually Suppose you want to track a week of Seattle temperatures (a legitimate non-small-talk topic there). The dataset is small enough to type by hand. ```haskell weather = D.fromNamedColumns [ ("Day", D.fromList ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday" :: T.Text]) , ("High Temperature (C)", D.fromList [24, 20, 22, 23, 25, 26, 26 :: Double]) , ("Low Temperature (C)", D.fromList [14, 13, 13, 13, 14, 15, 15 :: Double]) ] TIO.putStrLn $ D.toMarkdown weather ``` > > | Day
Text | High Temperature (C)
Double | Low Temperature (C)
Double | > | ------------|--------------------------------|------------------------------ | > | Monday | 24.0 | 14.0 | > | Tuesday | 20.0 | 13.0 | > | Wednesday | 22.0 | 13.0 | > | Thursday | 23.0 | 13.0 | > | Friday | 25.0 | 14.0 | > | Saturday | 26.0 | 15.0 | > | Sunday | 26.0 | 15.0 | `fromNamedColumns` takes a list of `(name, column)` pairs. For data without column names there is `fromUnnamedColumns`, which assigns numeric names automatically. ```haskell weatherUnnamed = D.fromUnnamedColumns [ D.fromList ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday" :: T.Text] , D.fromList [24, 20, 22, 23, 25, 26, 26 :: Double] , D.fromList [14, 13, 13, 13, 14, 15, 15 :: Double] ] TIO.putStrLn $ D.toMarkdown weatherUnnamed ``` > > | 0
Text | 1
Double | 2
Double | > | ----------|-------------|------------ | > | Monday | 24.0 | 14.0 | > | Tuesday | 20.0 | 13.0 | > | Wednesday | 22.0 | 13.0 | > | Thursday | 23.0 | 13.0 | > | Friday | 25.0 | 14.0 | > | Saturday | 26.0 | 15.0 | > | Sunday | 26.0 | 15.0 | Numeric column names are fine for a quick sanity-check, but always give your columns descriptive names before doing real analysis. ### Reading from a file Most data lives in files. CSV (comma-separated values) is the most common format. ```haskell housing <- D.readCsv "../data/housing.csv" TIO.putStrLn $ D.toMarkdown (D.take 5 housing) ``` > > | longitude
Double | latitude
Double | housing_median_age
Double | total_rooms
Double | total_bedrooms
Maybe Double | population
Double | households
Double | median_income
Double | median_house_value
Double | ocean_proximity
Text | > | --------------------|--------------------|------------------------------|-----------------------|--------------------------------|----------------------|----------------------|-------------------------|------------------------------|------------------------ | > | -122.23 | 37.88 | 41.0 | 880.0 | Just 129.0 | 322.0 | 126.0 | 8.3252 | 452600.0 | NEAR BAY | > | -122.22 | 37.86 | 21.0 | 7099.0 | Just 1106.0 | 2401.0 | 1138.0 | 8.3014 | 358500.0 | NEAR BAY | > | -122.24 | 37.85 | 52.0 | 1467.0 | Just 190.0 | 496.0 | 177.0 | 7.2574 | 352100.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 1274.0 | Just 235.0 | 558.0 | 219.0 | 5.6431000000000004 | 341300.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 1627.0 | Just 280.0 | 565.0 | 259.0 | 3.8462 | 342200.0 | NEAR BAY | `take n df` keeps the first `n` rows. It is the quickest way to eyeball a fresh dataset. The type of `D.take` is `Int -> DataFrame -> DataFrame` — an integer in, a dataframe in, a dataframe out. ### Opting into stronger type safety `F.col @Type "colName"` tells the compiler what type to expect in a column. If you get the type wrong you hear about it immediately. ```haskell -- Runtime-checked (flexible): D.mean (F.col @Double "High Temperature (C)") weather -- Compile-time-checked — $(D.declareColumns …) generates typed bindings: $(D.declareColumns weather) D.mean high_temperature_c weather ``` > > 23.714285714285715 > 23.714285714285715 The `$(D.declareColumns df)` splice inspects the dataframe at compile time and generates one typed binding per column (column names are sanitised into valid Haskell identifiers). If you try to use a column that does not exist, the program will not compile. --- ## Chapter 3: Exploring Your Data You have data. What now? First you need to understand its shape: what columns exist, what types they hold, how complete they are. Three functions cover most of this ground: * `D.take` — peek at the first few rows * `D.describeColumns` — schema: types, null counts, unique counts * `D.summarize` — descriptive statistics ### take and takeLast ```haskell TIO.putStrLn $ D.toMarkdown (D.take 10 housing) ``` > > | longitude
Double | latitude
Double | housing_median_age
Double | total_rooms
Double | total_bedrooms
Maybe Double | population
Double | households
Double | median_income
Double | median_house_value
Double | ocean_proximity
Text | > | --------------------|--------------------|------------------------------|-----------------------|--------------------------------|----------------------|----------------------|-------------------------|------------------------------|------------------------ | > | -122.23 | 37.88 | 41.0 | 880.0 | Just 129.0 | 322.0 | 126.0 | 8.3252 | 452600.0 | NEAR BAY | > | -122.22 | 37.86 | 21.0 | 7099.0 | Just 1106.0 | 2401.0 | 1138.0 | 8.3014 | 358500.0 | NEAR BAY | > | -122.24 | 37.85 | 52.0 | 1467.0 | Just 190.0 | 496.0 | 177.0 | 7.2574 | 352100.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 1274.0 | Just 235.0 | 558.0 | 219.0 | 5.6431000000000004 | 341300.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 1627.0 | Just 280.0 | 565.0 | 259.0 | 3.8462 | 342200.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 919.0 | Just 213.0 | 413.0 | 193.0 | 4.0368 | 269700.0 | NEAR BAY | > | -122.25 | 37.84 | 52.0 | 2535.0 | Just 489.0 | 1094.0 | 514.0 | 3.6591 | 299200.0 | NEAR BAY | > | -122.25 | 37.84 | 52.0 | 3104.0 | Just 687.0 | 1157.0 | 647.0 | 3.12 | 241400.0 | NEAR BAY | > | -122.26 | 37.84 | 42.0 | 2555.0 | Just 665.0 | 1206.0 | 595.0 | 2.0804 | 226700.0 | NEAR BAY | > | -122.25 | 37.84 | 52.0 | 3549.0 | Just 707.0 | 1551.0 | 714.0 | 3.6912000000000003 | 261100.0 | NEAR BAY | ```haskell TIO.putStrLn $ D.toMarkdown (D.takeLast 5 housing) ``` > > | longitude
Double | latitude
Double | housing_median_age
Double | total_rooms
Double | total_bedrooms
Maybe Double | population
Double | households
Double | median_income
Double | median_house_value
Double | ocean_proximity
Text | > | --------------------|--------------------|------------------------------|-----------------------|--------------------------------|----------------------|----------------------|-------------------------|------------------------------|------------------------ | > | -121.09 | 39.48 | 25.0 | 1665.0 | Just 374.0 | 845.0 | 330.0 | 1.5603 | 78100.0 | INLAND | > | -121.21 | 39.49 | 18.0 | 697.0 | Just 150.0 | 356.0 | 114.0 | 2.5568 | 77100.0 | INLAND | > | -121.22 | 39.43 | 17.0 | 2254.0 | Just 485.0 | 1007.0 | 433.0 | 1.7 | 92300.0 | INLAND | > | -121.32 | 39.43 | 18.0 | 1860.0 | Just 409.0 | 741.0 | 349.0 | 1.8672 | 84700.0 | INLAND | > | -121.24 | 39.37 | 16.0 | 2785.0 | Just 616.0 | 1387.0 | 530.0 | 2.3886 | 89400.0 | INLAND | ### describeColumns ```haskell TIO.putStrLn $ D.toMarkdown (D.describeColumns housing) ``` > > | Column Name
Text | # Non-null Values
Int | # Null Values
Int | Type
Text | > | --------------------|--------------------------|----------------------|------------- | > | total_bedrooms | 20433 | 207 | Maybe Double | > | ocean_proximity | 20640 | 0 | Text | > | median_house_value | 20640 | 0 | Double | > | median_income | 20640 | 0 | Double | > | households | 20640 | 0 | Double | > | population | 20640 | 0 | Double | > | total_rooms | 20640 | 0 | Double | > | housing_median_age | 20640 | 0 | Double | > | latitude | 20640 | 0 | Double | > | longitude | 20640 | 0 | Double | `describeColumns` tells you the name, type, number of non-null values, number of nulls, and number of unique values for every column. It is the first thing to run on any new dataset. ### summarize ```haskell TIO.putStrLn $ D.toMarkdown (D.summarize housing) ``` > > | Statistic
Text | longitude
Double | latitude
Double | housing_median_age
Double | total_rooms
Double | total_bedrooms
Double | population
Double | households
Double | median_income
Double | median_house_value
Double | > | ------------------|---------------------|--------------------|------------------------------|-----------------------|--------------------------|----------------------|----------------------|-------------------------|----------------------------- | > | Count | 20640.0 | 20640.0 | 20640.0 | 20640.0 | 20433.0 | 20640.0 | 20640.0 | 20640.0 | 20640.0 | > | Mean | -119.57 | 35.63 | 28.64 | 2635.76 | 537.87 | 1425.48 | 499.54 | 3.87 | 206855.82 | > | Minimum | -124.35 | 32.54 | 1.0 | 2.0 | 1.0 | 3.0 | 1.0 | 0.5 | 14999.0 | > | 25% | -121.8 | 33.93 | 18.0 | 1447.75 | 296.0 | 787.0 | 280.0 | 2.56 | 119600.0 | > | Median | -118.49 | 34.26 | 29.0 | 2127.0 | 435.0 | 1166.0 | 409.0 | 3.53 | 179700.0 | > | 75% | -118.01 | 37.71 | 37.0 | 3148.0 | 647.0 | 1725.0 | 605.0 | 4.74 | 264725.0 | > | Max | -114.31 | 41.95 | 52.0 | 39320.0 | 6445.0 | 35682.0 | 6082.0 | 15.0 | 500001.0 | > | StdDev | 2.0 | 2.14 | 12.59 | 2181.62 | 421.39 | 1132.46 | 382.33 | 1.9 | 115395.62 | > | IQR | 3.79 | 3.78 | 19.0 | 1700.25 | 351.0 | 938.0 | 325.0 | 2.18 | 145125.0 | > | Skewness | -0.3 | 0.47 | 6.0e-2 | 4.15 | 3.46 | 4.94 | 3.41 | 1.65 | 0.98 | `summarize` shows the mean, min, 25th percentile, median, 75th percentile, max, standard deviation, IQR, and skewness for every numeric column in one table. #### Aside: type errors Haskell's type errors are a conversation with the compiler, not a failure. Here are two common examples. Passing a character instead of an integer to `take`: ```haskell -- D.take '5' housing -- error: Couldn't match expected type 'Int' with actual type 'Char' -- Fix: D.take 5 housing ``` > Passing a `Double` column expression where an `Int` is expected: ```haskell -- D.mean (F.col @Int "longitude") housing -- error: Couldn't match type 'Double' with 'Int' for column "longitude" -- Fix: D.mean (F.col @Double "longitude") housing ``` > Errors tell you the exact location and what went wrong. Over time you learn to read them as precise hints. --- ## Chapter 4: Data Cleaning and Preparation Data from the real world is rarely clean. Haskell's type system makes common cleaning patterns safe and explicit. ### Handling missing data Potentially-missing values are represented by `Maybe`. `Just x` means the value is present; `Nothing` means it is absent. This is not a special dataframe convention — it is a core Haskell type. ```haskell messy = D.fromNamedColumns [ ("id", D.fromList [Just 1, Just 2, Nothing, Nothing :: Maybe Int]) , ("score", D.fromList [Just 6.5, Nothing, Nothing, Just 6.5 :: Maybe Double]) , ("rate", D.fromList [Just 3.0, Nothing, Nothing, Just 3.0 :: Maybe Double]) ] TIO.putStrLn $ D.toMarkdown messy ``` > > | id
Maybe Int | score
Maybe Double | rate
Maybe Double | > | ----------------|-----------------------|--------------------- | > | Just 1 | Just 6.5 | Just 3.0 | > | Just 2 | Nothing | Nothing | > | Nothing | Nothing | Nothing | > | Nothing | Just 6.5 | Just 3.0 | #### Filtering by nulls `filterJust col df` drops all rows where `col` is `Nothing` and unwraps the `Maybe`: ```haskell TIO.putStrLn $ D.toMarkdown (D.filterJust "id" messy) ``` > > | id
Int | score
Maybe Double | rate
Maybe Double | > | ----------|-----------------------|--------------------- | > | 1 | Just 6.5 | Just 3.0 | > | 2 | Nothing | Nothing | `filterAllJust df` keeps only rows where *every* column is non-null: ```haskell TIO.putStrLn $ D.toMarkdown (D.filterAllJust messy) ``` > > | id
Int | score
Double | rate
Double | > | ----------|-----------------|--------------- | > | 1 | 6.5 | 3.0 | The companions `filterNothing` and `filterAllNothing` do the opposite — they let you inspect the bad rows. #### Imputing missing values `impute expr default df` fills every `Nothing` in a column with a given value: ```haskell TIO.putStrLn $ D.toMarkdown (D.impute (F.col @(Maybe Int) "id") 0 messy) ``` > > | id
Int | score
Maybe Double | rate
Maybe Double | > | ----------|-----------------------|--------------------- | > | 1 | Just 6.5 | Just 3.0 | > | 2 | Nothing | Nothing | > | 0 | Nothing | Nothing | > | 0 | Just 6.5 | Just 3.0 | Notice the `@(Maybe Int)` type annotation — it tells the imputer what type the column holds. Passing the wrong type throws a clear runtime error: ```haskell -- D.impute (F.col @(Maybe Double) "id") 0 messy -- Exception: Type Mismatch — expected 'Maybe Double' but column is 'Maybe Integer' ``` > #### Imputing with a statistic `imputeWith` fills nulls with the result of an aggregation, e.g. the column mean: ```haskell TIO.putStrLn $ D.toMarkdown $ D.take 10 (D.imputeWith F.mean (F.col @(Maybe Double) "total_bedrooms") housing) ``` > > | longitude
Double | latitude
Double | housing_median_age
Double | total_rooms
Double | total_bedrooms
Double | population
Double | households
Double | median_income
Double | median_house_value
Double | ocean_proximity
Text | > | --------------------|--------------------|------------------------------|-----------------------|--------------------------|----------------------|----------------------|-------------------------|------------------------------|------------------------ | > | -122.23 | 37.88 | 41.0 | 880.0 | 129.0 | 322.0 | 126.0 | 8.3252 | 452600.0 | NEAR BAY | > | -122.22 | 37.86 | 21.0 | 7099.0 | 1106.0 | 2401.0 | 1138.0 | 8.3014 | 358500.0 | NEAR BAY | > | -122.24 | 37.85 | 52.0 | 1467.0 | 190.0 | 496.0 | 177.0 | 7.2574 | 352100.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 1274.0 | 235.0 | 558.0 | 219.0 | 5.6431000000000004 | 341300.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 1627.0 | 280.0 | 565.0 | 259.0 | 3.8462 | 342200.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 919.0 | 213.0 | 413.0 | 193.0 | 4.0368 | 269700.0 | NEAR BAY | > | -122.25 | 37.84 | 52.0 | 2535.0 | 489.0 | 1094.0 | 514.0 | 3.6591 | 299200.0 | NEAR BAY | > | -122.25 | 37.84 | 52.0 | 3104.0 | 687.0 | 1157.0 | 647.0 | 3.12 | 241400.0 | NEAR BAY | > | -122.26 | 37.84 | 42.0 | 2555.0 | 665.0 | 1206.0 | 595.0 | 2.0804 | 226700.0 | NEAR BAY | > | -122.25 | 37.84 | 52.0 | 3549.0 | 707.0 | 1551.0 | 714.0 | 3.6912000000000003 | 261100.0 | NEAR BAY | ### Removing duplicates `distinct df` keeps one copy of each unique row: ```haskell dupData = D.fromNamedColumns [ ("k1", D.fromList (take 6 (cycle ["one","two"]) ++ ["two"])) , ("k2", D.fromList [1, 1, 2, 3, 3, 4, 4 :: Int]) ] TIO.putStrLn $ D.toMarkdown (D.distinct dupData) ``` > > | k1
[Char] | k2
Int | > | -------------|---------- | > | two | 3 | > | one | 3 | > | one | 1 | > | two | 4 | > | two | 1 | > | one | 2 | ### Opting into stronger type safety After `$(D.declareColumns df)` any imputation or filter expression is checked at compile time. A typo in a column name becomes a compile error, not a runtime surprise. ```haskell $(D.declareColumns housing) -- Compile-time checked — 'total_bedrooms' must exist and be Maybe Double: TIO.putStrLn $ D.toMarkdown $ D.take 10 (D.imputeWith F.mean total_bedrooms housing) ``` > > | longitude
Double | latitude
Double | housing_median_age
Double | total_rooms
Double | total_bedrooms
Double | population
Double | households
Double | median_income
Double | median_house_value
Double | ocean_proximity
Text | > | --------------------|--------------------|------------------------------|-----------------------|--------------------------|----------------------|----------------------|-------------------------|------------------------------|------------------------ | > | -122.23 | 37.88 | 41.0 | 880.0 | 129.0 | 322.0 | 126.0 | 8.3252 | 452600.0 | NEAR BAY | > | -122.22 | 37.86 | 21.0 | 7099.0 | 1106.0 | 2401.0 | 1138.0 | 8.3014 | 358500.0 | NEAR BAY | > | -122.24 | 37.85 | 52.0 | 1467.0 | 190.0 | 496.0 | 177.0 | 7.2574 | 352100.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 1274.0 | 235.0 | 558.0 | 219.0 | 5.6431000000000004 | 341300.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 1627.0 | 280.0 | 565.0 | 259.0 | 3.8462 | 342200.0 | NEAR BAY | > | -122.25 | 37.85 | 52.0 | 919.0 | 213.0 | 413.0 | 193.0 | 4.0368 | 269700.0 | NEAR BAY | > | -122.25 | 37.84 | 52.0 | 2535.0 | 489.0 | 1094.0 | 514.0 | 3.6591 | 299200.0 | NEAR BAY | > | -122.25 | 37.84 | 52.0 | 3104.0 | 687.0 | 1157.0 | 647.0 | 3.12 | 241400.0 | NEAR BAY | > | -122.26 | 37.84 | 42.0 | 2555.0 | 665.0 | 1206.0 | 595.0 | 2.0804 | 226700.0 | NEAR BAY | > | -122.25 | 37.84 | 52.0 | 3549.0 | 707.0 | 1551.0 | 714.0 | 3.6912000000000003 | 261100.0 | NEAR BAY | --- ## Chapter 5: Data Transformation Most datasets need some reshaping before analysis. Take this hypothetical meat dataset: ```haskell foodOptions = ["bacon","pulled pork","bacon","pastrami","corned beef","bacon","pastrami","honey ham","nova lox" :: T.Text] measurements = [4, 3, 12, 6, 7.5, 8, 3, 5, 6 :: Double] meat = D.fromNamedColumns [ ("food", D.fromList foodOptions) , ("ounces", D.fromList measurements) ] TIO.putStrLn $ D.toMarkdown meat ``` > > | food
Text | ounces
Double | > | -------------|----------------- | > | bacon | 4.0 | > | pulled pork | 3.0 | > | bacon | 12.0 | > | pastrami | 6.0 | > | corned beef | 7.5 | > | bacon | 8.0 | > | pastrami | 3.0 | > | honey ham | 5.0 | > | nova lox | 6.0 | ### Adding derived columns `derive name expr df` adds a new column computed from an expression. ```haskell TIO.putStrLn $ D.toMarkdown (D.derive "kilograms" (F.col @Double "ounces" * 0.03) meat) ``` > > | food
Text | ounces
Double | kilograms
Double | > | -------------|------------------|-------------------- | > | bacon | 4.0 | 0.12 | > | pulled pork | 3.0 | 9.0e-2 | > | bacon | 12.0 | 0.36 | > | pastrami | 6.0 | 0.18 | > | corned beef | 7.5 | 0.22499999999999998 | > | bacon | 8.0 | 0.24 | > | pastrami | 3.0 | 9.0e-2 | > | honey ham | 5.0 | 0.15 | > | nova lox | 6.0 | 0.18 | ### Expressions and F.col `F.col @Type "name"` creates an *expression* — a typed reference to a column that can be combined with arithmetic operators, boolean operators, or custom functions. ``` F.col @Double "ounces" -- :: Expr Double F.col @Text "food" -- :: Expr Text ``` You can compose expressions: ```haskell roomsPerHousehold = D.derive "rooms_per_household" (F.col @Double "total_rooms" / F.col @Double "households") housing TIO.putStrLn $ D.toMarkdown (D.take 5 roomsPerHousehold) ``` > > | longitude
Double | latitude
Double | housing_median_age
Double | total_rooms
Double | total_bedrooms
Maybe Double | population
Double | households
Double | median_income
Double | median_house_value
Double | ocean_proximity
Text | rooms_per_household
Double | > | --------------------|--------------------|------------------------------|-----------------------|--------------------------------|----------------------|----------------------|-------------------------|------------------------------|-------------------------|------------------------------ | > | -122.23 | 37.88 | 41.0 | 880.0 | Just 129.0 | 322.0 | 126.0 | 8.3252 | 452600.0 | NEAR BAY | 6.984126984126984 | > | -122.22 | 37.86 | 21.0 | 7099.0 | Just 1106.0 | 2401.0 | 1138.0 | 8.3014 | 358500.0 | NEAR BAY | 6.238137082601054 | > | -122.24 | 37.85 | 52.0 | 1467.0 | Just 190.0 | 496.0 | 177.0 | 7.2574 | 352100.0 | NEAR BAY | 8.288135593220339 | > | -122.25 | 37.85 | 52.0 | 1274.0 | Just 235.0 | 558.0 | 219.0 | 5.6431000000000004 | 341300.0 | NEAR BAY | 5.8173515981735155 | > | -122.25 | 37.85 | 52.0 | 1627.0 | Just 280.0 | 565.0 | 259.0 | 3.8462 | 342200.0 | NEAR BAY | 6.281853281853282 | ### Lifting custom functions When the built-in arithmetic operators are not enough, `F.lift` applies any function to a column expression. ```haskell import Data.Text (Text) meatToAnimal :: Text -> Text meatToAnimal "bacon" = "pig" meatToAnimal "pulled pork" = "pig" meatToAnimal "pastrami" = "cow" meatToAnimal "corned beef" = "cow" meatToAnimal "honey ham" = "pig" meatToAnimal "nova lox" = "salmon" meatToAnimal _ = "unknown" TIO.putStrLn $ D.toMarkdown (D.derive "animal" (F.lift meatToAnimal (F.col @Text "food")) meat) ``` > > | food
Text | ounces
Double | animal
Text | > | -------------|------------------|--------------- | > | bacon | 4.0 | pig | > | pulled pork | 3.0 | pig | > | bacon | 12.0 | pig | > | pastrami | 6.0 | cow | > | corned beef | 7.5 | cow | > | bacon | 8.0 | pig | > | pastrami | 3.0 | cow | > | honey ham | 5.0 | pig | > | nova lox | 6.0 | salmon | ### Recoding values `F.recode mapping expr` is a concise way to map one set of values to another: ```haskell animalMapping = [("bacon","pig"),("pulled pork","pig"),("pastrami","cow"),("corned beef","cow"),("honey ham","pig"),("nova lox","salmon")] TIO.putStrLn $ D.toMarkdown (D.derive "animal2" (F.recode animalMapping (F.col @Text "food")) meat) ``` > > | food
Text | ounces
Double | animal2
Maybe [Char] | > | -------------|------------------|------------------------ | > | bacon | 4.0 | Just "pig" | > | pulled pork | 3.0 | Just "pig" | > | bacon | 12.0 | Just "pig" | > | pastrami | 6.0 | Just "cow" | > | corned beef | 7.5 | Just "cow" | > | bacon | 8.0 | Just "pig" | > | pastrami | 3.0 | Just "cow" | > | honey ham | 5.0 | Just "pig" | > | nova lox | 6.0 | Just "salmon" | ### Opting into stronger type safety After `$(D.declareColumns meat)`, column references are checked at compile time: ```haskell $(D.declareColumns meat) -- Using declared column bindings — compiler catches typos and type mismatches: TIO.putStrLn $ D.toMarkdown (D.derive "kilograms" (ounces * 0.03) meat) ``` > > | food
Text | ounces
Double | kilograms
Double | > | -------------|------------------|-------------------- | > | bacon | 4.0 | 0.12 | > | pulled pork | 3.0 | 9.0e-2 | > | bacon | 12.0 | 0.36 | > | pastrami | 6.0 | 0.18 | > | corned beef | 7.5 | 0.22499999999999998 | > | bacon | 8.0 | 0.24 | > | pastrami | 3.0 | 9.0e-2 | > | honey ham | 5.0 | 0.15 | > | nova lox | 6.0 | 0.18 | Accidentally using a `Text` column in arithmetic would be a compile error: ```haskell -- D.derive "wrong" (food * 0.03) meat -- error: No instance for Num (Expr Text) — food is Text, not a number ``` > ### The FrameM monad — pipelines without plumbing Every transformation so far has required threading the dataframe through manually: `df` in, `df'` out, `df''` out of that. For multi-step pipelines that grows tedious fast. `DataFrame.Monad` provides `FrameM`, a state-monad wrapper that threads the dataframe implicitly. ```haskell import DataFrame.Monad $(D.declareColumnsFromCsvWithOpts (D.defaultReadOptions{D.typeSpec = D.InferFromSample 300}) "../data/housing.csv") housing <- D.readCsv "../data/housing.csv" pipelined = execFrameM housing $ do isExpensive <- deriveM "is_expensive" (median_house_value .>=. 500000) roomsPerHousehold <- deriveM "rooms_per_household" (total_rooms / households) meanBeds <- inspectM (D.meanMaybe total_bedrooms) totalBedrooms <- imputeM total_bedrooms meanBeds filterWhereM (isExpensive .&&. roomsPerHousehold .>=. 7 .&&. totalBedrooms .>=. 200) TIO.putStrLn $ D.toMarkdown $ D.take 5 pipelined ``` > > | longitude
Double | latitude
Double | housing_median_age
Double | total_rooms
Double | total_bedrooms
Double | population
Double | households
Double | median_income
Double | median_house_value
Double | ocean_proximity
Text | is_expensive
Bool | rooms_per_household
Double | > | --------------------|--------------------|------------------------------|-----------------------|--------------------------|----------------------|----------------------|-------------------------|------------------------------|-------------------------|----------------------|------------------------------ | > | -122.24 | 37.86 | 52.0 | 1668.0 | 225.0 | 517.0 | 214.0 | 7.8521 | 500001.0 | NEAR BAY | True | 7.794392523364486 | > | -122.24 | 37.85 | 52.0 | 3726.0 | 474.0 | 1366.0 | 496.0 | 9.3959 | 500001.0 | NEAR BAY | True | 7.512096774193548 | > | -122.23 | 37.83 | 52.0 | 2990.0 | 379.0 | 947.0 | 361.0 | 7.8772 | 500001.0 | NEAR BAY | True | 8.282548476454293 | > | -122.22 | 37.82 | 39.0 | 2492.0 | 310.0 | 808.0 | 315.0 | 11.8603 | 500001.0 | NEAR BAY | True | 7.911111111111111 | > | -122.22 | 37.82 | 42.0 | 2991.0 | 335.0 | 1018.0 | 335.0 | 13.499 | 500001.0 | NEAR BAY | True | 8.928358208955224 | `$(D.declareColumnsFromCsvFile path)` generates compile-time column bindings by reading the CSV header at splice time (you don't need a live dataframe in scope) unlike `$(D.declareColumns df)` which requires a bound frame. Inside the `do`-block, `<-` binds the typed `Expr` returned by each step; those expressions can be reused in later steps (e.g. `isExpensive` in the final `filterWhereM`) without any extra plumbing. **Extracting results alongside the final frame** — `runFrameM` returns a pair `(a, DataFrame)`: ```haskell ((isExp, bedrooms), housingEnriched) = runFrameM housing $ do isExp <- deriveM "is_expensive" (median_house_value .>=. 500000) meanBeds <- inspectM (D.meanMaybe total_bedrooms) bedrooms <- imputeM total_bedrooms meanBeds pure (isExp, bedrooms) ``` > The three exit functions: | Function | Returns | Use when | |---|---|---| | `execFrameM df block` | `DataFrame` | you only need the final frame | | `runFrameM df block` | `(a, DataFrame)` | you need extracted values *and* the frame | | `evalFrameM df block` | `a` | you only need the extracted values | Available monadic operations: `deriveM`, `imputeM`, `filterWhereM`, `inspectM`, `renameM`. --- ## Chapter 6: Data Loading, Storage, and File Formats Real workflows involve reading data from many sources and writing results back to disk. This chapter covers the file I/O functions you will use most. ### CSV files You have already seen `D.readCsv`. Under the hood it calls `readCsvWithOpts` with sensible defaults. ```haskell housingFull <- D.readCsv "../data/housing.csv" TIO.putStrLn $ D.toMarkdown (D.take 3 housingFull) ``` > > | longitude
Double | latitude
Double | housing_median_age
Double | total_rooms
Double | total_bedrooms
Maybe Double | population
Double | households
Double | median_income
Double | median_house_value
Double | ocean_proximity
Text | > | --------------------|--------------------|------------------------------|-----------------------|--------------------------------|----------------------|----------------------|-------------------------|------------------------------|------------------------ | > | -122.23 | 37.88 | 41.0 | 880.0 | Just 129.0 | 322.0 | 126.0 | 8.3252 | 452600.0 | NEAR BAY | > | -122.22 | 37.86 | 21.0 | 7099.0 | Just 1106.0 | 2401.0 | 1138.0 | 8.3014 | 358500.0 | NEAR BAY | > | -122.24 | 37.85 | 52.0 | 1467.0 | Just 190.0 | 496.0 | 177.0 | 7.2574 | 352100.0 | NEAR BAY | ### TSV files Tab-separated files work exactly like CSV: ```haskell -- housingTsv <- D.readTsv "data/housing.tsv" -- TIO.putStrLn $ D.toMarkdown (D.take 3 housingTsv) ``` > ### Custom separators and options `ReadOptions` controls the separator character, header handling, date format, and more. ```haskell pipeOpts = D.defaultReadOptions { D.columnSeparator = '|' } -- pipeDelimited <- D.readCsvWithOpts pipeOpts "data/data.psv" ``` > The `HeaderSpec` type determines how the first row is treated: * `UseFirstRow` (default) — the first row contains column names. * `NoHeader` — there is no header; columns get numeric names. * `ProvideNames ["a","b","c"]` — supply names explicitly. ```haskell noHeaderOpts = D.defaultReadOptions { D.headerSpec = D.NoHeader } -- D.readCsvWithOpts noHeaderOpts "data/raw.csv" namedOpts = D.defaultReadOptions { D.headerSpec = D.ProvideNames ["longitude","latitude","age","rooms","bedrooms","population","households","income","value","proximity"] } -- D.readCsvWithOpts namedOpts "data/housing_no_header.csv" ``` > ### Writing CSV files `D.writeCsv path df` writes a dataframe back to disk: ```haskell housingSubset = D.select ["longitude","latitude","median_house_value"] housingFull D.writeCsv "/tmp/housing_subset.csv" housingSubset ``` > You can write any character-separated format: ```haskell D.writeSeparated '|' "/tmp/housing_pipe.psv" housingSubset ``` > ### Parquet files Parquet is a columnar binary format common in data engineering pipelines: ```haskell -- parquetDf <- D.readParquet "data/housing.parquet" -- TIO.putStrLn $ D.toMarkdown (D.take 5 parquetDf) ``` > For a directory of Parquet shards (e.g. from a Spark job): ```haskell -- shardDf <- D.readParquetFiles "data/housing_shards/" ``` > ### Inspecting the schema after load Always run `describeColumns` on freshly loaded data to confirm types and check for unexpected nulls: ```haskell TIO.putStrLn $ D.toMarkdown (D.describeColumns housingFull) ``` > > | Column Name
Text | # Non-null Values
Int | # Null Values
Int | Type
Text | > | --------------------|--------------------------|----------------------|------------- | > | total_bedrooms | 20433 | 207 | Maybe Double | > | ocean_proximity | 20640 | 0 | Text | > | median_house_value | 20640 | 0 | Double | > | median_income | 20640 | 0 | Double | > | households | 20640 | 0 | Double | > | population | 20640 | 0 | Double | > | total_rooms | 20640 | 0 | Double | > | housing_median_age | 20640 | 0 | Double | > | latitude | 20640 | 0 | Double | > | longitude | 20640 | 0 | Double | ### Opting into stronger type safety After loading, use `F.cast` to produce a column expression with the exact type you expect. Any row whose value cannot be converted becomes `Nothing` in the result: ```haskell -- Retype a text column to Double, converting unparseable values to Nothing: withIncomeCast = D.derive "income_cast" (F.cast @Double "median_income") housingFull TIO.putStrLn $ D.toMarkdown (D.take 5 withIncomeCast) ``` ### Streaming large files with DataFrame.Lazy All of the above reads the entire file into memory upfront. For datasets larger than available RAM, `DataFrame.Lazy` offers a pull-based streaming executor: operations build a logical plan tree and nothing is read from disk until `runDataFrame` is called. The optimizer pushes `filter` predicates down to the scan, so unneeded rows are discarded before any column is allocated. The Lazy path requires an explicit schema — there is no inference. Build one with `schemaType @T`: ```haskell -- cabal: build-depends: containers import qualified DataFrame.Lazy as L import DataFrame.Internal.Schema (Schema (..), schemaType) import qualified Data.Map.Strict as M housingSchema = Schema $ M.fromList [ ("longitude", schemaType @Double) , ("latitude", schemaType @Double) , ("housing_median_age", schemaType @Double) , ("total_rooms", schemaType @Double) , ("total_bedrooms", schemaType @(Maybe Double)) , ("population", schemaType @Double) , ("households", schemaType @Double) , ("median_income", schemaType @Double) , ("median_house_value", schemaType @Double) , ("ocean_proximity", schemaType @T.Text) ] ``` Build and run a lazy pipeline: ```haskell lazyQuery = L.scanCsv housingSchema "../data/housing.csv" |> L.filter (F.col @Double "median_house_value" .>=. 300000) |> L.select ["longitude","latitude","median_house_value","ocean_proximity"] |> L.take 10 lazyResult <- L.runDataFrame lazyQuery TIO.putStrLn $ D.toMarkdown lazyResult ``` Key API: | Function | Description | |---|---| | `L.scanCsv schema path` | stream a CSV file | | `L.scanSeparated sep schema path` | stream any character-delimited file | | `L.scanParquet schema path` | stream a Parquet file | | `L.fromDataFrame df` | lift an eager frame into the lazy plan | | `L.filter`, `L.select`, `L.derive` | lazy counterparts of the eager operations | | `L.take`, `L.sortBy`, `L.groupBy`, `L.join` | same semantics, deferred execution | | `L.runDataFrame plan` | execute the plan and materialise a `DataFrame` | The optimizer automatically reorders `filter` before `select` and prunes columns that are not referenced downstream — columns excluded by `L.select` are never allocated at the scan level. --- ## Chapter 7: Data Cleaning and Type Coercion Real datasets come with type mismatches, messy strings, and dates in a dozen formats. This chapter shows how to clean them up. ### Type coercion Three functions handle column re-typing with different failure modes: | Function | On failure | Return type | |---|---|---| | `F.cast @T "col"` | returns `Nothing` | `Expr (Maybe T)` | | `F.castWithDefault v "col"` | returns `v` | `Expr T` | | `F.castEither @T "col"` | returns `Left originalText` | `Expr (Either Text T)` | #### Lenient cast — nullify bad values ```haskell messyNums = D.fromNamedColumns [ ("raw", D.fromList ["1.5", "2.0", "bad", "3.7", "" :: T.Text]) ] withCast = D.derive "as_double" (F.cast @Double "raw") messyNums TIO.putStrLn $ D.toMarkdown withCast ``` #### Cast with default — fill bad values ```haskell withDefault = D.derive "as_double" (F.castWithDefault 0.0 "raw") messyNums TIO.putStrLn $ D.toMarkdown withDefault ``` #### castEither — audit bad rows `castEither` returns `Right` for successes and `Left` for failures, so you can inspect every bad row before deciding what to do: ```haskell withAudit = D.derive "audit" (F.castEither @Double "raw") messyNums TIO.putStrLn $ D.toMarkdown withAudit ``` Haskell forces you to handle the `Left` case before you can use the values downstream — making data quality issues visible at the type level. ### Text operations Three functions manipulate `Text` columns inside expressions. #### splitOn `F.splitOn delim expr` splits each string at the delimiter, producing a list: ```haskell emails = D.fromNamedColumns [ ("email", D.fromList ["alice@example.com", "bob@haskell.org", "cara@data.io" :: T.Text]) ] emailParts = D.derive "parts" (F.splitOn "@" (F.col @T.Text "email")) emails TIO.putStrLn $ D.toMarkdown emailParts ``` #### match `F.match pattern expr` returns `Just` the first regex match, or `Nothing` if there is none: ```haskell domains = D.derive "domain" (F.match "[a-z]+\\.[a-z]+" (F.col @T.Text "email")) emails TIO.putStrLn $ D.toMarkdown domains ``` #### matchAll `F.matchAll pattern expr` returns a list of *all* matches: ```haskell withWords = D.derive "words" (F.matchAll "[a-z]+" (F.col @T.Text "email")) emails TIO.putStrLn $ D.toMarkdown withWords ``` ### Date operations #### parseDate `F.parseDate fmt expr` parses a text column into a `Day`, returning `Nothing` for values that do not match the format: ```haskell import Data.Time (Day) events = D.fromNamedColumns [ ("name", D.fromList ["release","conference","deadline" :: T.Text]) , ("date_text", D.fromList ["2025-03-01","2025-06-15","2025-09-30" :: T.Text]) ] withDates = D.derive "date" (F.parseDate @Day "%Y-%m-%d" (F.col @T.Text "date_text")) events TIO.putStrLn $ D.toMarkdown withDates ``` #### daysBetween `F.daysBetween expr1 expr2` computes the number of days between two `Day` expressions: ```haskell -- This requires two Day columns; here we illustrate the pattern: -- D.derive "days_until" (F.daysBetween today_col deadline_col) df ``` ### Opting into stronger type safety `F.castEither` returns `Either Text a` — Haskell's type system ensures you address the `Left` (failure) case before proceeding. The alternative coercion functions (`cast`, `castWithDefault`) have types that make the tradeoff explicit in the signature. ```haskell $(D.declareColumns withAudit) -- 'audit' is Expr (Either Text Double) — you must handle Left before using it as a number TIO.putStrLn $ D.toMarkdown (D.take 5 withAudit) ``` --- ## Chapter 8: Data Wrangling — Join, Combine, and Reshape Real analyses often involve multiple tables. This chapter shows how to combine them. ### Vertical concatenation with <> `(<>)` stacks two dataframes with compatible schemas. Columns missing from one side are padded with `Nothing`. The `import DataFrame.Operations.Merge ()` at the top of this file brings the `Semigroup` instance into scope. ```haskell firstHalf = D.take 5 housing secondHalf = D.range (5, 10) housing combined = firstHalf <> secondHalf TIO.putStrLn $ D.toMarkdown combined ``` To stack a list of frames: ```haskell chunks = map (\i -> D.range (i, i+3) housing) [0, 4, 8] stacked = mconcat chunks TIO.putStrLn $ D.toMarkdown stacked ``` ### Horizontal concatenation with ||| `(|||)` joins two frames side by side (column-wise). Both must have the same number of rows. ```haskell leftCols = D.select ["longitude","latitude"] housing rightCols = D.select ["median_house_value","ocean_proximity"] housing sideBy = leftCols ||| rightCols TIO.putStrLn $ D.toMarkdown (D.take 5 sideBy) ``` ### SQL-style joins Joins combine rows from two tables based on a shared key. ```haskell customers = D.fromNamedColumns [ ("customer_id", D.fromList [1, 2, 3, 4 :: Int]) , ("name", D.fromList ["Alice","Bob","Cara","Dave" :: T.Text]) , ("city", D.fromList ["Seattle","Portland","Austin","Denver" :: T.Text]) ] orders = D.fromNamedColumns [ ("customer_id", D.fromList [1, 2, 2, 3, 5 :: Int]) , ("product", D.fromList ["laptop","keyboard","mouse","monitor","tablet" :: T.Text]) , ("amount", D.fromList [1200.0, 85.0, 30.0, 350.0, 600.0 :: Double]) ] ``` #### Inner join — only matching rows ```haskell innerResult = D.innerJoin ["customer_id"] customers orders TIO.putStrLn $ D.toMarkdown innerResult ``` Dave (id=4) has no orders; customer 5 has no customer record. Neither appears in an inner join. #### Left join — keep all left rows ```haskell leftResult = D.leftJoin ["customer_id"] customers orders TIO.putStrLn $ D.toMarkdown leftResult ``` Dave appears with `Nothing` for order columns. #### Right join — keep all right rows ```haskell rightResult = D.rightJoin ["customer_id"] customers orders TIO.putStrLn $ D.toMarkdown rightResult ``` The orphan order (customer_id=5) appears with `Nothing` for customer columns. #### Full outer join — keep everything ```haskell outerResult = D.fullOuterJoin ["customer_id"] customers orders TIO.putStrLn $ D.toMarkdown outerResult ``` All rows from both sides appear; unmatched rows get `Nothing` in the other side's columns. ### Reshaping #### select and exclude ```haskell TIO.putStrLn $ D.toMarkdown (D.select ["longitude","latitude","median_house_value"] (D.take 5 housing)) ``` ```haskell TIO.putStrLn $ D.toMarkdown (D.exclude ["longitude","latitude"] (D.take 5 housing)) ``` #### rename `D.rename old new df` renames a single column; `D.renameMany pairs df` renames several at once: ```haskell renamedHousing = D.renameMany [ ("median_house_value", "price") , ("median_income", "income") ] housing TIO.putStrLn $ D.toMarkdown (D.take 3 renamedHousing) ``` #### sortBy ```haskell TIO.putStrLn $ D.toMarkdown (D.take 5 (D.sortBy D.Descending ["median_house_value"] housing)) ``` #### range `D.range (start, end) df` slices rows from index `start` (inclusive) to `end` (exclusive): ```haskell TIO.putStrLn $ D.toMarkdown (D.range (10, 15) housing) ``` ### Opting into stronger type safety After a join the combined schema is known. Use `$(D.declareColumns …)` to get compile-time bindings for all columns of the joined result: ```haskell $(D.declareColumns innerResult) -- All columns of innerResult are now bound with their exact types. -- Typos or type mismatches become compile errors, not runtime surprises. enriched = D.derive "total_with_tax" (amount * 1.1) innerResult TIO.putStrLn $ D.toMarkdown enriched ``` --- ## Chapter 9: Plotting and Visualization A picture is worth a thousand rows. This chapter shows two layers of plotting: 1. **Low-level Granite.Svg functions** — full control over bins, axes, and titles. 2. **High-level `D.*` wrappers** — one-liners that inspect the dataframe directly. The `import Granite.Svg` at the top of this file brings the low-level functions into scope. ### Histograms Histograms show the distribution of a single numeric variable. ```haskell import qualified DataFrame.Display.Terminal.Plot as P houseValues = D.columnAsList (F.col @Double "median_house_value") housing TIO.putStrLn $ histogram (bins 30 140000 502000) houseValues defPlot { legendPos = LegendBottom , xFormatter = \_ _ v -> T.pack (show (round v :: Int)) , xNumTicks = 8 , yNumTicks = 5 , plotTitle = "Median House Prices of California Houses ($)" } ``` The high-level wrapper is even shorter — it uses a default bin count of 30: ```haskell P.plotHistogram "median_house_value" housing ``` ### Scatter plots Scatter plots reveal relationships between two numeric variables. ```haskell incomes = D.columnAsList (F.col @Double "median_income") housing values = D.columnAsList (F.col @Double "median_house_value") housing TIO.putStrLn $ scatter [("income vs value", zip incomes values)] defPlot { plotTitle = "Income vs House Value" } ``` ```haskell P.plotScatter "median_income" "median_house_value" housing ``` ### Bar charts Bar charts are good for categorical counts. ```haskell P.plotBars "ocean_proximity" housing ``` ### Pie charts ```haskell P.plotPie "ocean_proximity" Nothing housing ``` ### Box plots Box plots summarise the five-number summary (min, Q1, median, Q3, max) for one or more numeric columns. ```haskell P.plotBoxPlots ["median_house_value", "median_income"] housing ``` ### Line graphs Line graphs show trends along a continuous x-axis. The second argument is a list of y-axis column names. ```haskell ages = D.columnAsList (F.col @Double "housing_median_age") housing TIO.putStrLn $ lineGraph [("age vs value", zip ages values)] defPlot { plotTitle = "Housing Age vs Value" } ``` ```haskell P.plotLines "housing_median_age" ["median_house_value"] housing ``` ### Correlation matrix (heatmap) A correlation matrix shows pairwise Pearson correlations across all numeric columns. ```haskell P.plotCorrelationMatrix housing ``` ### Opting into stronger type safety Using `F.col @Double` in `D.columnAsList` ensures only numeric columns reach the plotting functions. Passing a `Text` column to a histogram is a compile error: ```haskell -- D.columnAsList (F.col @Double "ocean_proximity") housing -- error: column "ocean_proximity" has type Text, not Double ``` --- ## Chapter 10: Data Aggregation and Group Operations Grouping is the backbone of most summary analyses. This chapter covers `groupBy` and the rich set of aggregation functions. ### groupBy and aggregate `D.groupBy keys df` returns a `GroupedDataFrame`. Calling `D.aggregate exprs grouped` reduces each group to a single row. ```haskell import DataFrame.Operators (as) grouped = D.groupBy ["ocean_proximity"] housing summary = D.aggregate [ F.count (F.col @Double "median_house_value") `as` "count" , F.mean (F.col @Double "median_house_value") `as` "mean_value" , F.median (F.col @Double "median_house_value") `as` "median_value" , F.maximum (F.col @Double "median_house_value") `as` "max_value" , F.minimum (F.col @Double "median_house_value") `as` "min_value" ] grouped TIO.putStrLn $ D.toMarkdown summary ``` ### Full aggregation function menu | Function | Description | Return type | |---|---|---| | `F.count expr` | number of elements | `Expr Int` | | `F.sum expr` | sum | `Expr a` | | `F.sumMaybe expr` | sum ignoring Nothing | `Expr a` | | `F.mean expr` | arithmetic mean | `Expr Double` | | `F.meanMaybe expr` | mean ignoring Nothing | `Expr Double` | | `F.median expr` | median | `Expr Double` | | `F.medianMaybe expr` | median ignoring Nothing | `Expr Double` | | `F.minimum expr` | minimum | `Expr a` | | `F.maximum expr` | maximum | `Expr a` | | `F.stddev expr` | standard deviation | `Expr Double` | | `F.variance expr` | variance | `Expr Double` | | `F.percentile n expr` | n-th percentile | `Expr Double` | | `F.mode expr` | most frequent value | `Expr a` | | `F.collect expr` | all values as a list | `Expr [a]` | ### Multi-key grouping Group by multiple columns by passing a longer key list: ```haskell $(D.declareColumns meat) meatGrouped = D.groupBy ["food"] meat meatSummary = D.aggregate [ F.count (F.col @T.Text "food") `as` "count" , F.sum ounces `as` "total_oz" , F.mean ounces `as` "mean_oz" ] meatGrouped TIO.putStrLn $ D.toMarkdown meatSummary ``` ### Value counts with frequencies `D.frequencies expr df` returns a frequency table — row counts and percentages for each unique value: ```haskell TIO.putStrLn $ D.toMarkdown (D.frequencies (F.col @T.Text "ocean_proximity") housing) ``` ### Pearson correlation `D.correlation col1 col2 df` computes the Pearson correlation coefficient between two columns. It returns `Maybe Double` because the result is undefined for constant columns: ```haskell D.correlation "median_income" "median_house_value" housing ``` ### Derived columns on aggregated results Aggregate first, then derive new columns from the summary: ```haskell totalRows = fromIntegral (D.nRows housing) :: Double withShare = D.derive "pct_of_total" (F.toDouble (F.col @Int "count") / F.lit totalRows * F.lit 100.0) summary TIO.putStrLn $ D.toMarkdown withShare ``` ### Z-score normalisation pipeline A full group-normalise pipeline: group → compute mean and stddev → derive z-scores. ```haskell incomeByProx = D.aggregate [ F.mean (F.col @Double "median_income") `as` "mean_income" , F.stddev (F.col @Double "median_income") `as` "stddev_income" ] (D.groupBy ["ocean_proximity"] housing) TIO.putStrLn $ D.toMarkdown incomeByProx ``` ### Opting into stronger type safety After aggregation the result has a new schema. A new `$(D.declareColumns …)` gives you compile-time bindings for that schema: ```haskell $(D.declareColumns summary) -- All aggregated columns are now typed. mean_value :: Expr Double etc. ranked = D.sortBy D.Descending [F.name mean_value] summary TIO.putStrLn $ D.toMarkdown ranked ``` A type-level mistake on an aggregated column — say, treating `count` (an `Int`) as a `Double` — is caught immediately by the compiler rather than silently producing wrong numbers at runtime. --- ## Chapter 11: Compile-Time Schema Safety with DataFrame.Typed ### Motivation Throughout this guide we have used `F.col @Type "colName"` to reference columns. The expressions are typed (the `@Type` annotation is checked), but the schema of the *dataframe* is not in the Haskell type. This creates a subtle foot-gun: an `Expr` built against one dataframe can be silently applied to a completely different dataframe — the compiler will accept it, but at runtime you get either a missing-column error or, worse, wrong results from a same-named column with different semantics. `DataFrame.Typed` solves this by moving the full column schema into the type system. The schema becomes a type-level list of `DT.Column "name" Type` entries. If a column does not exist in a `TypedDataFrame`, or if you use it with the wrong type, the code will not compile. The trade-off is more explicit upfront ceremony (a TH splice, a freeze call, and the `DataKinds` extension). When you want that guarantee for a production pipeline, it is worth it. ### Generating a schema **At compile time from a CSV file** — `$(DT.deriveSchemaFromCsvFile "Housing" path)` reads the header and generates a type alias called `Housing`: ```haskell import qualified DataFrame.Typed as DT $(DT.deriveSchemaFromCsvFile "Housing" "../data/housing.csv") ``` **By hand** — equivalent to what the TH splice generates: ```haskell type Housing = [ DT.Column "longitude" Double , DT.Column "latitude" Double , DT.Column "housing_median_age" Double , DT.Column "total_rooms" Double , DT.Column "total_bedrooms" (Maybe Double) , DT.Column "population" Double , DT.Column "households" Double , DT.Column "median_income" Double , DT.Column "median_house_value" Double , DT.Column "ocean_proximity" T.Text ] ``` ### Freezing a runtime dataframe `D.readCsv` returns a plain `DataFrame`. `DT.freezeWithError @Housing` checks that it conforms to the `Housing` schema and produces a `TypedDataFrame Housing`. Any mismatch — wrong column type, missing column — is caught here, near the top of the pipeline, rather than several steps in: ```haskell thousing <- either (error . show) id . DT.freezeWithError @Housing <$> D.readCsv "../data/housing.csv" ``` `DT.thaw` converts back to a plain `DataFrame` when you need to pass the result to a function that does not know about typed frames. ### Typed transforms `DT.col @"colName"` looks up the column in the schema at compile time. The type of the expression is inferred automatically — no `@Type` annotation needed. `DT.derive @"newCol"` adds the column to the schema type so subsequent steps can reference it: ```haskell typedResult = thousing |> DT.derive @"rooms_per_household" (DT.col @"total_rooms" / DT.col @"households") |> DT.derive @"bedrooms_per_household" (DT.col @"total_bedrooms" / DT.col @"households") TIO.putStrLn $ D.toMarkdown (DT.thaw typedResult) ``` `DT.impute @"colName" defaultValue` fills `Nothing` values in a nullable column; like `derive`, it updates the schema type (the column becomes non-optional after imputation). ### Typed group and aggregate ```haskell typedGrouped = thousing |> DT.groupBy @'["ocean_proximity"] |> DT.aggregate (DT.as @"count" (DT.count (DT.col @"median_house_value"))) TIO.putStrLn $ D.toMarkdown (DT.thaw typedGrouped) ``` `DT.groupBy @'["col1","col2"]` takes a type-level list of column names (note the leading `'` for a promoted list). Each output column is named with `DT.as @"resultCol" aggregationExpr`; chain multiple aggregations with `(.)` from Prelude (no terminator needed). ### When to use each layer | Layer | Best for | |---|---| | Eager string API (`F.col @T "name"`) | exploration, quick scripts, one-off analyses | | `FrameM` | multi-step transformation pipelines where threading `df` by hand is noisy | | `DataFrame.Lazy` | files larger than RAM; push-down filters; ETL pipelines | | `DataFrame.Typed` | production pipelines where schema correctness must be guaranteed at compile time | The layers compose: you can `L.fromDataFrame (DT.thaw typedResult)` to hand a typed result to the lazy executor, or use `FrameM` for the messy cleaning phase and `Typed` for the aggregation phase.