Exploring and reducing the big table

Stage two: getting from tens of millions of rows to something you can hold

Draft. The code runs and the argument holds; the prose has not been copy-edited.

Thirty-two million rows go in and about a thousand come back. The whole of this page is about where that reduction happens, because it decides whether the analysis is possible at all.

library(DBI)
library(dplyr)
library(dbplyr)
library(tibble)
library(glue)
library(cli)
library(sf)
library(ggplot2)

catalog <- Sys.getenv("DATABRICKS_CATALOG")
schema <- Sys.getenv("DATABRICKS_SCHEMA")

con <- dbConnect(
  odbc::databricks(),
  httpPath = Sys.getenv("DATABRICKS_PATH"),
  DefaultStringColumnLength = 65535
)

tbl_name <- function(table) {
  glue("{catalog}.{schema}.{table}")
}

wq_tbl <- function(table) {
  tbl(con, I(tbl_name(table)))
}

Looking before reducing

Everything in this section is ordinary dplyr. None of it loads the table, and you can tell which by where collect() is: there isn’t one yet.

readings <- wq_tbl("hydrology_readings")
measures <- wq_tbl("hydrology_measures")

Start with the shape of the thing. This runs on the warehouse and returns three numbers.

readings |>
  summarise(
    rows = n(),
    first_day = min(date),
    last_day = max(date)
  ) |>
  collect()
Warning: Missing values are always removed in SQL aggregation functions.
Use `na.rm = TRUE` to silence this warning
This warning is displayed once every 8 hours.
# A tibble: 1 × 3
      rows first_day  last_day  
   <int64> <date>     <date>    
1 32540721 2015-01-01 2024-12-30

Ten years of daily data. Before trusting any of it, look at what the quality flag says, because a mean over unchecked values is not the mean you want.

readings |>
  count(quality, sort = TRUE) |>
  collect()
# A tibble: 5 × 2
  quality          n
  <chr>      <int64>
1 Good      26281624
2 Unchecked  3165682
3 Suspect    1665539
4 Estimated  1013873
5 Missing     414003

About four fifths is Good. The rest is not junk, but it is not comparable either, and filtering to Good is a decision this analysis makes explicitly rather than by accident.

The readings table does not say what it is measuring. That lives on the measures table, which is small.

measures |>
  count(parameter_name, period_name, sort = TRUE) |>
  head(8) |>
  collect()
# A tibble: 8 × 3
  parameter_name   period_name       n
  <chr>            <chr>       <int64>
1 Level            daily          5276
2 Dissolved Oxygen sub-daily      3807
3 Flow             daily          3309
4 GroundwaterLevel <NA>           3095
5 Level            15min          2638
6 Temperature      sub-daily      1948
7 Conductivity     sub-daily      1942
8 PH               sub-daily      1852

A step that has to move

The analysis wants daily mean flow per station. The obvious way to write that is to filter the readings first and join afterwards, which is the wrong order:

readings |>
  filter(quality == "Good") |>
  inner_join(measures, by = join_by(measure == notation)) |>
  filter(parameter == "flow", period == 86400L)

That is not wrong in its answer, it is wrong in where the work lands. The join runs against every Good row in the table before anything narrows it to flow. Selecting the measures first means the join has roughly a thousand rows on one side instead of twenty-six million.

The rule generalises: filter the small side before the join, not the large side after it.

Reaching a function dbplyr will not translate

Picking daily mean flow needs a match on the end of value_statistic, and the natural R way to write that does not survive the trip:

filter(measures, grepl("mean", value_statistic))

dbplyr translates a great deal, and grepl() is not part of it. The query reaches the server with grepl still in it and fails there, not here, with UNRESOLVED_ROUTINE. That is the signature of a translation gap rather than a mistake in your logic.

The escape hatch is dplyr::sql(), which passes a fragment through untouched. You are writing Spark SQL at that point, so the dialect is theirs and not R’s.

flow_measures <- measures |>
  filter(
    parameter == "flow",
    period == 86400L,
    sql("value_statistic LIKE '%/mean'")
  )

flow_measures |>
  summarise(measures = n()) |>
  collect()
# A tibble: 1 × 1
  measures
   <int64>
1     1103

Where the line falls in this pipeline

Here is the whole reduction, with the one line that matters marked. Everything above collect() runs on Databricks; everything below runs in your session.

per_station <- readings |>
  inner_join(
    flow_measures,
    by = join_by(measure == notation)
  ) |>
  filter(
    quality == "Good",
    !is.na(value),
    value >= 0
  ) |>
  summarise(
    .by = station_id,
    n_days = n(),
    mean_flow = mean(value, na.rm = TRUE),
    sd_flow = sd(value, na.rm = TRUE)
  ) |>
  filter(n_days >= 1000) |>
  # <== everything above this line ran on Databricks
  collect() |>
  mutate(cv = sd_flow / mean_flow)

per_station
# A tibble: 1,029 × 5
   station_id                                 n_days mean_flow   sd_flow    cv
   <chr>                                     <int64>     <dbl>     <dbl> <dbl>
 1 8a2f47f1-711e-4805-8536-a49bfda4d06c         3632   0.652    0.904    1.39 
 2 8b07239d-fb98-4f27-b182-def7799b6c34_2044    3608   0.970    0.928    0.957
 3 962530ad-afe9-49e5-8fcd-33af504efdf6         3520   0.160    0.0728   0.454
 4 a9417599-e466-4b53-babe-0c81229c4328         3454   4.32     5.30     1.23 
 5 7ca88e3b-d821-4093-951e-1ecd25e78ef5         3201   0.285    0.497    1.74 
 6 a97cc80d-f8dd-4fa9-a0db-482ab1b41f71         3373   0.318    0.235    0.739
 7 bce8bf9b-9b67-4bee-9c90-3c7f65c3444e         2912   0.975    0.833    0.854
 8 b9933a62-f326-4d77-9206-ebb335161831         3092  21.2     23.5      1.11 
 9 88be649b-6ea9-4daa-b81b-7767cbc5b995         1564   0.00309  0.000753 0.243
10 977e3ab5-ec6e-4040-acf2-8a178818a5c8         3285   0.288    0.296    1.03 
# ℹ 1,019 more rows

The mutate() sits below the line deliberately. It could run either side, and putting it in the session keeps the SQL to the part that has to be SQL.

n_days >= 1000 drops stations with less than about three years of record. That is a judgement about what a variability estimate is worth, not a technical constraint.

It is worth seeing what that judgement costs, because a filter on record length could quietly have been a filter on geography. It is not: the stations that survive are thinned out of the whole network rather than taken from one part of it.

The coordinates are eastings and northings on the British National Grid (EPSG 27700), so coord_sf() is given that CRS explicitly rather than left to infer one. Stating it is the habit worth copying: a map drawn from unprojected latitude and longitude is stretched north to south at this latitude, and nothing warns you.

# station_xy.rds carries easting and northing for every station, from the
# same query [Joining tables that do not share a key](spatial.qmd) uses.
station_xy <- readRDS("station_xy.rds") |>
  st_as_sf(coords = c("easting", "northing"), crs = 27700) |>
  mutate(kept = station_id %in% per_station$station_id)

ggplot(station_xy) +
  geom_sf(data = ~filter(.x, !kept), colour = "#CFCFCF", size = 0.35) +
  geom_sf(data = ~filter(.x, kept), colour = "#447099", size = 0.5) +
  coord_sf(crs = 27700, datum = 27700) +
  theme_void(base_size = 11)

Gauging stations across England, with the thousand-odd stations that have at least a thousand days of record picked out against the rest. The survivors are spread across the whole network rather than concentrated in any region.

What came back

cli_alert_info(
  "{nrow(per_station)} stations, \\
   {round(as.numeric(object.size(per_station)) / 1024)} KB"
)
ℹ 1029 stations, 138 KB

Thirty-two and a half million rows became about a thousand, and a few hundred kilobytes. Everything after this page is ordinary R on an ordinary data frame.

The coefficient of variation is the number the rest of the example uses. A station with a CV near zero has a steady flow; a high CV means a river that is mostly low and occasionally in flood.

per_station |>
  summarise(
    median_cv = median(cv, na.rm = TRUE),
    min_cv = min(cv, na.rm = TRUE),
    max_cv = max(cv, na.rm = TRUE)
  )
# A tibble: 1 × 3
  median_cv min_cv max_cv
      <dbl>  <dbl>  <dbl>
1      1.12 0.0878   3.09

Checking the reduction against the server

The count came back as a single number, so it is worth confirming that number was computed over what you think it was.

server_side <- readings |>
  inner_join(
    flow_measures,
    by = join_by(measure == notation)
  ) |>
  filter(quality == "Good", !is.na(value), value >= 0) |>
  summarise(
    rows = n(),
    stations = n_distinct(station_id)
  ) |>
  collect()

server_side
# A tibble: 1 × 2
     rows stations
  <int64>  <int64>
1 3239134     1066

The station count here is higher than the thousand-odd that came back, and that difference is the n_days >= 1000 filter rather than anything going wrong.

saveRDS(per_station, "per_station.rds")
dbDisconnect(con)

Next

The spatial join.


This page rests on: the readings table holds about 32.5 million rows spanning 2015 to 2024; roughly 80% of readings carry quality Good; grepl() has no Spark SQL translation and fails server-side with UNRESOLVED_ROUTINE, so dplyr::sql() is needed to match on value_statistic; filtering the measures table before the join rather than the readings table after it is what keeps the join small; the reduction yields about a thousand stations and a few hundred kilobytes.