Manipulating penguin data à la dplyr with H2O

Carlos Ortega

1 What this package does

h2oplyr provides familiar transformation verbs while keeping the observations inside an H2O cluster. The only external runtime dependency is h2o. The tidytable API inspires parts of the interface, but neither tidytable nor dplyr participates in a calculation. knitr and rmarkdown are optional documentation-building dependencies.

The workflow is:

CSV file → H2OFrame → h2o_tbl → remote operations → H2OFrame

An h2o_tbl is a small R object containing a remote frame handle and grouping column names. R assembles expressions and inspects metadata; H2O processes the rows. Scalar reductions and category domains can reach R, but no transformation uses a local data frame, matrix or vector as its computational backend. For display only, the previews below request a few scalar values and categorical labels. Displaying a value necessarily brings that value to the reader; it does not change where selection, aggregation, joins and reshaping run.

This tutorial is for R users familiar with selecting columns, filtering rows and grouping data. You will learn to inspect remote results, handle missing values, check joins and verify a reshape round trip. Java and a matching H2O client/server are the only external runtime requirements.

This tutorial uses measurements of 344 penguins from three species in the Palmer Archipelago, collected during 2007–2009. The CSV is included in the package, so examples do not depend on a future download or a palmerpenguins installation.

Practical question Tools used below Visible evidence
Which variables and rows do I need? h_select(), h_filter(), h_drop_na() Values, column names and row counts
How do I derive and summarize measurements? h_mutate(), h_summarize(), h_across() Derived units and species means
Can I combine and reshape without losing information? h_left_join(), pivot verbs Preserved row counts and matching values
How do ordering and sampling affect the result? Slice verbs, h_arrange(), h_lag() Sample rows, tied maxima and lag values

Reading the examples. Each transformation is followed by values returned from H2O and a green check based on an executed assertion. A preview is a small window into the result; the caption reports the full result dimensions.

2 Connect once, then import

Install the package archive using install.packages(path, repos = NULL, type = "source") after installing h2o. Start a matching H2O server explicitly:

# Run this yourself, once per session. Loading h2oplyr never starts a server.
h2o::h2o.init(nthreads = 2, max_mem_size = "2G")
library(h2oplyr)
How the result previews work — complete rendering code

These helpers belong to this vignette, not to the package API. preview_h2o() accepts a remote table, a title, a few column names and a row limit (at most six). It prints HTML and returns no local analytical table. Numbers use scalar H2O reductions; text uses the categorical domain of a single selected value. H2O may internally cache small metadata previews. This is a deliberately small, slow display routine, not a data export facility. verify_output() stops the render if its condition fails. All helpers are defined here before their first use.

# Escape labels before placing them inside HTML.
html_text <- function(x) {
  x <- gsub("&", "&amp;", x, fixed = TRUE)
  x <- gsub("<", "&lt;", x, fixed = TRUE)
  gsub(">", "&gt;", x, fixed = TRUE)
}

# Show bounded remote values without creating a local data.frame.
preview_h2o <- function(x, title, columns = names(as_h2o(x)), rows = 4L) {
  frame <- as_h2o(x)
  stopifnot(inherits(frame, "H2OFrame"), rows >= 1L, rows <= 6L,
            all(columns %in% names(frame)))
  types <- unlist(h2o::h2o.getTypes(frame[, columns]), use.names = FALSE)
  shown <- min(rows, nrow(frame))
  cat('\n<div class="result"><h3>', html_text(title), '</h3>', sep = "")
  cat('<p class="result-meta">H2OFrame · ', nrow(frame), ' rows × ',
      ncol(frame), ' columns · showing ', shown, ' rows and ',
      length(columns), ' columns</p><div class="table-scroll"><table><thead><tr>',
      sep = "")
  for (j in seq_along(columns)) {
    alignment <- if (types[j] %in% c("int", "real")) ' class="num"' else ""
    cat('<th scope="col"', alignment, '>', html_text(columns[j]), '</th>', sep = "")
  }
  cat('</tr></thead><tbody>')
  for (i in seq_len(shown)) {
    cat('<tr>')
    for (j in seq_along(columns)) {
      # Two-step indexing keeps the cell as a remote frame in H2O.
      cell <- frame[i, ][, columns[j]]
      stopifnot(inherits(cell, "H2OFrame"))
      numeric_column <- types[j] %in% c("int", "real")
      alignment <- if (numeric_column) ' class="num"' else ""
      if (h2o::h2o.sum(is.na(cell)) == 1) {
        value <- '<span class="missing">NA</span>'
      } else if (numeric_column) {
        number <- h2o::h2o.sum(cell)
        value <- if (number == floor(number)) sprintf("%.0f", number) else sprintf("%.2f", number)
      } else {
        # Re-encode in H2O so the domain contains only this cell's label.
        label <- h2o::h2o.levels(h2o::h2o.asfactor(h2o::h2o.ascharacter(cell)))
        stopifnot(length(label) == 1L)
        value <- html_text(as.character(label[[1L]]))
      }
      cat('<td', alignment, '>', value, '</td>', sep = "")
    }
    cat('</tr>')
  }
  cat('</tbody></table></div></div>\n\n')
  invisible(NULL)
}

# Only a successful, evaluated assertion receives a green check.
verify_output <- function(condition, label) {
  stopifnot(isTRUE(condition))
  cat('\n<p class="check"><strong>Verified.</strong> ', html_text(label), '</p>\n\n', sep = "")
}
# Import the bundled CSV and confirm its dimensions.
penguins <- h_penguins()
stopifnot(nrow(as_h2o(penguins)) == 344)

The imported penguins

H2OFrame · 344 rows × 8 columns · showing 4 rows and 4 columns

species island bill_length_mm body_mass_g
Adelie Torgersen 39.10 3750
Adelie Torgersen 39.50 3800
Adelie Torgersen 40.30 3250
Adelie Torgersen NA NA

Verified. 344 observations and 8 variables imported into H2O.

h_penguins() uploads the local CSV through h2o.uploadFile() and declares "NA" as missing in every column. Species, island and sex are categorical; measurements are numeric. For your own files, upload directly with h2o::h2o.uploadFile() and wrap the returned frame with h2o_tbl().

The default table printer displays metadata only. This vignette adds bounded value previews so you can inspect the results directly. h_collect(penguins) and as_h2o(penguins) both return the remote H2OFrame, preserving the backend contract. This is an intentional change from the original standalone prototype.

The eight variables are species, island, bill length and depth (mm), flipper length (mm), body mass (g), sex and year. A missing measurement appears as NA, not as zero. The same penguin can have one measurement missing and others present.

Recognize the object. The result is an H2OFrame wrapped by h2o_tbl. The small HTML table above is a display of selected values, not a new data backend.

3 Select only the columns needed

# Keep relevant columns, then rename and order the bill measurements.
bills <- h_select(penguins, species, starts_with("bill_"))
measurements <- h_select(penguins, where(is.numeric))
renamed <- h_rename(bills, bill_length = bill_length_mm)
relocated <- h_relocate(renamed, species, .before = bill_length)

Selected bill measurements

H2OFrame · 344 rows × 3 columns · showing 4 rows and 3 columns

species bill_length_mm bill_depth_mm
Adelie 39.10 18.70
Adelie 39.50 17.40
Adelie 40.30 18
Adelie NA NA

Numeric variables

H2OFrame · 344 rows × 5 columns · showing 3 rows and 5 columns

bill_length_mm bill_depth_mm flipper_length_mm body_mass_g year
39.10 18.70 181 3750 2007
39.50 17.40 186 3800 2007
40.30 18 195 3250 2007

Renamed and relocated columns

H2OFrame · 344 rows × 3 columns · showing 4 rows and 3 columns

species bill_length bill_depth_mm
Adelie 39.10 18.70
Adelie 39.50 17.40
Adelie 40.30 18
Adelie NA NA

Verified. Selection, renaming and final column order match the requested schema.

Selection operates on column names and H2O schema types. where() supports the base predicates is.numeric, is.character and is.factor; arbitrary predicates are deliberately rejected. all_of() requires every requested column, while any_of() allows absent names. Ranges such as species:island and exclusions such as -year are supported. A grouped selection retains its grouping columns.

Takeaway. Selection changes the schema, not the observations. All three previews retain the original 344 rows.

4 Missing values and filtering

# Compare complete-case filtering with two measurement thresholds.
complete_penguins <- h_drop_na(penguins)
stopifnot(nrow(as_h2o(complete_penguins)) == 333)
cat("Complete observations:", nrow(as_h2o(complete_penguins)), "\n")
#> Complete observations: 333
large <- h_filter(penguins, body_mass_g >= 5000)
cat("Penguins weighing at least 5000 g:", nrow(as_h2o(large)), "\n")
#> Penguins weighing at least 5000 g: 67
bill_range <- h_filter(penguins, h_between(bill_length_mm, 40, 50))
cat("Penguins with bill length from 40 to 50 mm:", nrow(as_h2o(bill_range)), "\n")
#> Penguins with bill length from 40 to 50 mm: 190

Complete cases

H2OFrame · 333 rows × 8 columns · showing 4 rows and 4 columns

species bill_length_mm body_mass_g sex
Adelie 39.10 3750 male
Adelie 39.50 3800 female
Adelie 40.30 3250 female
Adelie 36.70 3450 female

Body mass of at least 5,000 g

H2OFrame · 67 rows × 8 columns · showing 4 rows and 2 columns

species body_mass_g
Gentoo 5700
Gentoo 5700
Gentoo 5400
Gentoo 5200

Bill length between 40 and 50 mm

H2OFrame · 190 rows × 8 columns · showing 4 rows and 2 columns

species bill_length_mm
Adelie 40.30
Adelie 42
Adelie 41.10
Adelie 42.50

Verified. Complete-case count and both filter boundaries checked over the full remote results.

A missing filter condition is dropped. The expression interpreter propagates missing comparisons explicitly, because H2O’s raw comparison behavior can differ from R’s. h_filter_out() negates the combined predicate and also drops missing conditions. Use is.na() when you intend to select missing measurements.

# Select the missing body masses explicitly.
missing_mass <- h_filter(penguins, is.na(body_mass_g))
stopifnot(nrow(as_h2o(missing_mass)) == 2)

The two missing body masses

H2OFrame · 2 rows × 8 columns · showing 2 rows and 3 columns

species bill_length_mm body_mass_g
Adelie NA NA
Gentoo NA NA

Verified. Both retained rows have a missing body mass.

Dropping all incomplete rows yields 333 penguins, whereas a mass-only analysis can retain 342. Those are different analysis populations. Replacing missing body mass with zero would invent a biologically impossible measurement, so this tutorial removes missing mass only where it is needed.

Takeaway. Choose the missing-data rule for the question: requiring every variable to be complete removes more rows than requiring body mass alone.

5 Derive columns without moving observations

# Drop only missing mass, then derive units and a mass category.
prepared <- penguins |>
  h_drop_na(body_mass_g) |>
  h_mutate(
    mass_kg = body_mass_g / 1000,
    mass_band = h_case_when(
      body_mass_g >= 5000 ~ "large",
      body_mass_g >= 3500 ~ "medium",
      TRUE ~ "small"
    )
  )
stopifnot(nrow(as_h2o(prepared)) == 342)
cat("Total measured mass (kg):",
    h2o::h2o.sum(as_h2o(prepared)[, "mass_kg"]), "\n")
#> Total measured mass (kg): 1437

Mass in grams, kilograms and bands

H2OFrame · 342 rows × 10 columns · showing 4 rows and 4 columns

species body_mass_g mass_kg mass_band
Adelie 3750 3.75 medium
Adelie 3800 3.80 medium
Adelie 3250 3.25 small
Adelie 3450 3.45 small

Verified. 342 measured masses retained; the kg-to-g conversion agrees for every row.

Named mutations run sequentially, so a later expression can use a newly created column. Arithmetic, comparisons, selected H2O mathematical and text functions, and the package’s vector helpers are supported. Calls to arbitrary R functions or other package namespaces raise an error. h_transmute() retains only the new named columns. Grouped mutation is not implemented: ungroup explicitly rather than accidentally calculate a global value inside a group.

The native |> pipe and the package’s %.% pipe can both chain these verbs.

Takeaway. Read the derived columns side by side. The numerical assertion checks every measured mass, not just the displayed rows.

6 Summarize by species

# Compute counts and means inside H2O, grouped by species.
by_species <- h_summarize(
  penguins,
  observations = n(),
  mean_mass_g = mean(body_mass_g, na.rm = TRUE),
  mean_bill_mm = mean(bill_length_mm, na.rm = TRUE),
  .by = species
)

Species-level summary

H2OFrame · 3 rows × 4 columns · showing 3 rows and 4 columns

species observations mean_mass_g mean_bill_mm
Adelie 152 3700.66 38.79
Chinstrap 68 3733.09 48.83
Gentoo 124 5076.02 47.50

Verified. Three groups account for all 344 observations.

The output has one row per species. n() counts all observations; each mean removes missing values independently. Gentoo penguins have the highest mean body mass in this sample. This is a descriptive comparison, not a causal effect of species or island.

.by applies grouping to this call only. The equivalent persistent grouping form is h_group_by(penguins, species) |> h_summarize(...); summaries themselves return ungrouped results. .add = TRUE adds grouping columns, and h_group_vars() reports the current grouping metadata.

Supported reductions are n(), n_distinct(column), sum, mean, min, max, sd, var and median. Each reduction takes a column, optionally with na.rm. Create transformed columns with h_mutate() before aggregating. The adapter rejects empty-input summaries explicitly.

Takeaway. Group sizes count penguins, while each mean uses its own non-missing measurements. A group count is not necessarily the denominator of every mean.

7 Transform several columns consistently

# Apply unit conversion and grouped means to multiple columns.
scaled <- h_mutate(
  bills,
  h_across(starts_with("bill_"), list(cm = function(z) z / 10))
)
average_bills <- bills |>
  h_drop_na() |>
  h_group_by(species) |>
  h_summarize_across(starts_with("bill_"), list(average = mean))

Millimetres converted to centimetres

H2OFrame · 344 rows × 5 columns · showing 4 rows and 4 columns

species bill_length_mm bill_length_mm_cm bill_depth_mm_cm
Adelie 39.10 3.91 1.87
Adelie 39.50 3.95 1.74
Adelie 40.30 4.03 1.80
Adelie NA NA NA

Mean bill measurements by species

H2OFrame · 3 rows × 3 columns · showing 3 rows and 3 columns

species bill_length_mm_average bill_depth_mm_average
Adelie 38.79 18.35
Chinstrap 48.83 18.42
Gentoo 47.50 14.98

Verified. Across conversion and the three grouped summaries checked.

h_across() builds column expressions with names such as bill_length_mm_cm. The one-argument function body is interpreted through the same remote expression rules as h_mutate(). Summary function identity is used, so an alias such as average = mean still computes the mean. Across summaries default to na.rm = FALSE; this example deliberately removes missing bill measurements first. The standalone h_mutate_across() wrapper is retained for compatibility.

Takeaway. Across applies the same expression to several columns; the suffix records the transformation or summary that produced each output.

8 Join summaries back to observations

# Attach a unique species summary to each observed mass.
enriched <- h_left_join(
  prepared, by_species,
  by = "species", relationship = "many-to-one"
)
stopifnot(nrow(as_h2o(enriched)) == nrow(as_h2o(prepared)))
enriched <- h_mutate(enriched, mass_minus_species_mean = body_mass_g - mean_mass_g)

Observed mass versus its species mean

H2OFrame · 342 rows × 14 columns · showing 4 rows and 4 columns

species body_mass_g mean_mass_g mass_minus_species_mean
Adelie 2850 3700.66 -850.66
Adelie 2850 3700.66 -850.66
Adelie 2900 3700.66 -800.66
Adelie 2900 3700.66 -800.66

Verified. The join preserves all 342 measured penguins and matches a species mean to each.

The relationship check ensures the right table has at most one record per species, preventing accidental multiplication of penguin rows. Inner, left, right and full equijoins are available. h_semi_join() selects left rows with a match; h_anti_join() selects those without one. Named keys map different column names, for example by = c(species = "species_code").

A full join is implemented using a left join plus unmatched right rows, all inside H2O. Missing-key matching follows H2O, and join row order is unspecified. Always use h_arrange() when subsequent calculations depend on ordering.

Takeaway. A valid many-to-one join must preserve the left row count. Sorting the preview makes it readable; join order itself is unspecified.

9 Reshape measurements from wide to long and back

# Use species-level summaries so the ID is naturally unique.
wide <- h_summarize(
  penguins,
  bill_length_mm = mean(bill_length_mm, na.rm = TRUE),
  bill_depth_mm = mean(bill_depth_mm, na.rm = TRUE),
  .by = species
)
long <- h_pivot_longer(
  wide, starts_with("bill_"),
  names_to = "measurement", values_to = "mean_mm"
)
stopifnot(nrow(as_h2o(long)) == 6)
restored <- h_pivot_wider(long, measurement, mean_mm)
stopifnot(nrow(as_h2o(restored)) == 3)

Before pivoting: one row per species

H2OFrame · 3 rows × 3 columns · showing 3 rows and 3 columns

species bill_length_mm bill_depth_mm
Adelie 38.79 18.35
Chinstrap 48.83 18.42
Gentoo 47.50 14.98

Long format: one row per measurement

H2OFrame · 6 rows × 3 columns · showing 6 rows and 3 columns

species measurement mean_mm
Adelie bill_length_mm 38.79
Adelie bill_depth_mm 18.35
Chinstrap bill_length_mm 48.83
Chinstrap bill_depth_mm 18.42
Gentoo bill_length_mm 47.50
Gentoo bill_depth_mm 14.98

Back to wide format

H2OFrame · 3 rows × 3 columns · showing 3 rows and 3 columns

species bill_length_mm bill_depth_mm
Adelie 38.79 18.35
Chinstrap 48.83 18.42
Gentoo 47.50 14.98

Verified. Wide → long → wide preserves both mean measurements for every species.

The long result contains six species/measurement combinations. Widening gets the output names from the remote categorical domain and combines values with remote joins. Repeated ID/name combinations are rejected: decide on an aggregation before reshaping. This avoids silently choosing one measurement or creating a local list-column. values_fill replaces missing result cells, including explicitly missing input values.

For text transformations, h_separate() uses H2O regular-expression splitting. h_unite() uses H2O categorical interaction with encoded original underscores; its current separator cannot contain dollar signs or backslashes. High-cardinality interaction domains can consume substantial cluster memory.

Takeaway. The round-trip check compares both measurements across all species. Matching row counts alone would not prove that the values survived.

10 Sampling, ties and order-dependent calculations

# Sample reproducibly, retain tied maxima and compute a sorted lag.
sample_penguins <- h_slice_sample(prepared, n = 12, seed = 2026)
stopifnot(nrow(as_h2o(sample_penguins)) == 12)
heaviest <- h_slice_max(prepared, body_mass_g, n = 3, with_ties = TRUE)
cat("Rows among the three heaviest positions, including ties:",
    nrow(as_h2o(heaviest)), "\n")
#> Rows among the three heaviest positions, including ties: 4
ordered <- h_arrange(prepared, body_mass_g)
ordered <- h_mutate(ordered, previous_mass = h_lag(body_mass_g))

A seeded sample of 12 penguins

H2OFrame · 12 rows × 10 columns · showing 4 rows and 3 columns

species body_mass_g bill_length_mm
Adelie 2975 37.50
Adelie 3550 39.70
Gentoo 5550 50
Adelie 3350 35.50

The heaviest positions, including ties

H2OFrame · 4 rows × 10 columns · showing 4 rows and 2 columns

species body_mass_g
Gentoo 6300
Gentoo 6050
Gentoo 6000
Gentoo 6000

Previous mass after sorting

H2OFrame · 342 rows × 11 columns · showing 4 rows and 3 columns

species body_mass_g previous_mass
Chinstrap 2700 NA
Adelie 2850 2700
Adelie 2850 2850
Adelie 2900 2850

Verified. Sample size is 12; the first lag is missing and every later lag matches the preceding mass.

Sampling uses H2O random columns. Sampling with replacement creates draw indices in H2O and joins them to rows. With seed = NULL (the default), no seed is passed to H2O; supply a seed explicitly when reproducibility is needed. Identical seeds are reproducible on the same frame and cluster layout; they are not a promise of identical samples across versions or distributed layouts. with_ties = TRUE can return more than n rows.

Lag, lead, cumulative operations and h_fill() use current row order. Penguins are not a longitudinal time series: the lag example illustrates an ordered comparison, not a temporal interpretation. Grouped windows and grouped slicing are rejected in this release.

Takeaway. A seeded sample is reproducible within the documented setting. A lag is meaningful only after choosing an order; penguins here are not a longitudinal panel.

11 Validation and current boundaries

Every exported function has an executable example with an assertion. The package also includes regression tests for missing values, aggregation, ties, empty slices, join multiplicity, invalid expressions and input preservation. Integration tests compare H2O function bindings before and after execution without modifying the H2O namespace. A static audit rejects local-table calls in package functions. Additional maintainer-only download instrumentation is kept outside the package.

Run all examples and regressions against a cluster you started explicitly:

h2o::h2o.init(nthreads = 2, max_mem_size = "2G")
library(h2oplyr)
source(system.file("examples", "examples.R", package = "h2oplyr"))
for (nm in names(examples)) eval(examples[[nm]])
source(system.file("examples", "regressions.R", package = "h2oplyr"))

This package is an explicit subset of tidy-style manipulation, not a complete drop-in replacement for dplyr or tidytable. It has no local rowwise/map backend, non-equality joins, automatic list-column nesting, arbitrary R callbacks, grouped mutation or implicit duplicate-cell pivot aggregation. h_bind_rows() requires matching column-name sets. h_nest_by() returns a list of remote group handles and is intended for a modest number of groups.

When no cluster is connected, rebuilding this vignette retains the code but skips H2O execution. The distributed HTML was built against an explicitly connected server. The package never starts or shuts down a user’s cluster while loading, printing, or running its ordinary examples.

12 Reproduce and interpret the checks

The green checks accompany the tutorial’s calculations. The installed package also contains 73 asserted API examples and semantic regression tests; those are a separate suite, not 73 examples all reproduced on this page. The commands in “Connect once” and “Validation” are instructions to run yourself, rather than claims that this HTML launched a cluster or reran the complete package test suite.

Executed with H2O. R 4.6.1; h2oplyr 0.2.6; h2o 3.44.0.3. All tutorial assertions completed during rendering.

Try changing the mass threshold or selecting another species, then rerun the section and inspect both the values and the assertion. Do not infer a successful transformation merely from seeing an object name or from a silent stopifnot().

13 Short glossary

14 Data provenance

Data: Horst AM, Hill AP, Gorman KB (2020), palmerpenguins: Palmer Archipelago (Antarctica) penguin data, doi:10.5281/zenodo.3960218. The data are available under CC0. Measurements were collected by Kristen Gorman and Palmer Station LTER. The research description is Gorman KB, Williams TD, Fraser WR (2014), doi:10.1371/journal.pone.0090081. The bundled CSV’s source, retrieval date and SHA-256 are recorded in system.file("extdata", "PROVENANCE.md", package = "h2oplyr").