R → Julia
Translating the book’s walk-throughs
The book’s R walk-throughs are its closest thing to a reference implementation, so they are what this port translates from. The mapping accumulates here project by project, noting where the two languages genuinely disagree rather than merely spell things differently.
Reading data
| R | Julia |
|---|---|
read.csv("f.csv") |
CSV.read("f.csv", DataFrame) |
read.csv("f.csv", skip = 1) |
CSV.read("f.csv", DataFrame; header = 2) |
read.csv("f.csv", na.strings = "***") |
CSV.read("f.csv", DataFrame; missingstring = "***") |
read_excel("f.xlsx") |
XLSX.readtable("f.xlsx", "Sheet1") \|> DataFrame |
setwd("...") |
not needed — rawpath() resolves from the repo root |
head(df) |
first(df, 6) |
str(df) |
describe(df) |
nrow(df) / ncol(df) |
nrow(df) / ncol(df) |
names(df)[1] <- "Year" |
rename!(df, 1 => :Year) |
skip = 1 becomes header = 2, not skipto. R counts lines to throw away; CSV.jl names the line the header is on. Off-by-one here silently turns your header row into data.
Subsetting and reshaping
| R | Julia |
|---|---|
df[df$Month == 6, ] |
subset(df, :Month => ByRow(==(6))) |
subset(df, Year >= 1951 & Year <= 1980) |
subset(df, :Year => ByRow(y -> 1951 <= y <= 1980)) |
df[, c("Jun", "Jul", "Aug")] |
df[:, [:Jun, :Jul, :Aug]] |
unlist(df[, c("Jun", "Jul")]) |
vec(Matrix(df[:, [:Jun, :Jul]])) |
merge(a, b) |
innerjoin(a, b; on = :Year) |
factor(x) |
categorical(x) |
cut(x, breaks) |
cut(x, breaks) (CategoricalArrays.jl) |
na.rm = TRUE |
skipmissing(x) |
merge(a, b) with no by guesses the join key from shared column names. innerjoin makes you name it. The explicit version is worth the extra characters — a silent join on the wrong shared column is a hard bug to see.
Summary statistics
| R | Julia |
|---|---|
mean(x) |
mean(x) |
var(x) |
var(x) |
sd(x) |
std(x) |
quantile(x, c(0.3, 0.7)) |
quantile(x, [0.3, 0.7]) |
mean(x > 0.5) |
mean(x .> 0.5) |
cor(x, y) |
cor(x, y) |
mosaic::mean(y ~ g) |
combine(groupby(df, :g), :y => mean) |
mosaic::var(y ~ g) |
combine(groupby(df, :g), :y => var) |
table(x) |
freqtable(x) or countmap(x) |
Two that agree where you might expect them not to:
varis the sample variance in both, dividing byn − 1. R has no option; Julia’svar(x; corrected = false)gives the population form. The book means the sample variance, so the default is right.quantileuses the same default. R’stype = 7and Julia’s default are the same linear-interpolation rule, so deciles match exactly. R offers nine types and Julia takesalpha/betaparameters; neither default needs touching here.
The mosaic package’s formula interface (mean(anomaly ~ period)) is the one idiom with no direct Julia counterpart. groupby + combine does the same job more verbosely and more explicitly — you name the output column, so a table of grouped statistics arrives already labelled.
Charts
| R | Julia (Makie / AlgebraOfGraphics) |
|---|---|
plot(x, y, type = "l") |
lines(x, y) |
plot(x, y) |
scatter(x, y) |
lines(x, y2) |
lines!(ax, x, y2) |
abline(h = 0) |
hlines!(ax, 0) |
abline(v = 1980) |
vlines!(ax, 1980) |
text(x, y, "label") |
text!(ax, x, y; text = "label") |
legend("topleft", ...) |
Legend(fig[1, 2], ax) or axislegend(ax) |
hist(x, breaks = seq(...)) |
hist(x; bins = ...), or freqtable_binned + barplot |
col =, lwd =, xlab = |
color =, linewidth =, xlabel = |
mfrow = c(2, 2) |
fig[i, j] grid positions |
ts(x, start = c(1880, 1), frequency = 12) |
keep Year/Month columns; build a Date |
The ! suffix is the whole mental model: lines creates a new axis, lines! adds to an existing one. R’s plot / lines split does the same thing without saying so.
ts() objects have no Julia equivalent and aren’t missed. R needs them so that plot knows to label the x-axis in years; in Julia you construct a real date column and the axis follows from the data.
The dual-axis chart
Walk-through 1.8 plots temperature and CO₂ on one set of axes with two different y-scales, using par(new = TRUE) to draw the second series over the first and axis(side = 4) to give it its own scale on the right.
This port does not reproduce it. Where the two scales line up is an arbitrary choice by whoever drew the chart, and moving one changes how strongly the series appear to track without changing a number.
Three alternatives:
- Two stacked panels sharing an x-axis; each series keeps its own units.
- Index both to a common base —
index_to(x, 1)puts both at 100 in the first period — and plot on one axis. Requires both series to have a meaningful zero, which rules it out for anomaly data (see Project 1 Q3.4). - State the correlation coefficient, which is what the question asks for.
Project 1 uses the two-panel form and reports the correlation.
Soft scope: the one that actually bites
R’s for loop body shares the global environment, so this accumulates:
total <- 0
for (i in 1:3) total <- total + i # total is 6The direct Julia translation silently does not, at top level:
total = 0
for i in 1:3
total += i # warns, and creates a NEW local each iteration
end
total # still 0Julia’s soft scope rule: a for, while or comprehension body at global scope treats assignment to an existing global as creating a local instead. It emits a warning, but a warning is easy to miss and the value is silently wrong — or the name is undefined when you read it back.
Three fixes, in order of preference:
# 1. Wrap in `let` - the counter becomes a real local, no warning
result = let
total = 0
for i in 1:3
total += i
end
total
end
# 2. Build it functionally and skip the accumulator
total = sum(1:3)
# 3. Declare `global` - works, but a sign the code wants restructuring
total = 0
for i in 1:3
global total += i
endInside a function this problem does not exist, because function bodies are hard scope. It only appears at top level — which is exactly where notebook and script code lives.
A trap specific to this repository: Quarto’s Julia engine wraps each cell in a way that makes the naive version work, so code that renders correctly on these pages can still fail when copied into a plain .jl script. Both Project 3 and Project 4 hit this. The pages use let blocks and up-front construction so that the code works either way.
let is fix #1 only when you initialise inside it
Fix #1 above works because total = 0 sits inside the let. Reach for let to rebind an outer name and it fails, which is the opposite trap:
df = load()
let keep = df.x .> 0
df = df[keep, :] # UndefVarError: `df` not defined in local scope
endlet is a hard scope, so df = ... declares a new local df, and the right-hand side then reads that local before it has a value. It errors rather than corrupting anything, which is the good case — but the message points at df being undefined when df is plainly defined one line up, so it reads like a bug in the language.
Project 8 hit this while filtering four survey waves in sequence. The fix is not global; it is a function that takes a frame and returns a new one:
function require_complete(df, cols; asked_in)
keep = .!in.(df.wave, Ref(asked_in)) .| complete_in(df, cols)
return df[keep, :]
end
clean = require_complete(clean, ["income"]; asked_in = ["2008-2010"])The rule of thumb: let for a value you build and hand back, a function for a transformation you apply repeatedly. Neither needs global.
Long tables lose their middle
Quarto’s Julia engine displays each cell’s result through an IOContext carrying :limit => true. DataFrames honours that the way it would for a terminal: past about 26 rows it prints the head and tail and replaces everything between with a ⋮ row.
On a web page there is no reason to elide anything, and the failure is quiet — the table looks complete unless you count the rows. Project 8 shipped a 46-country table with 20 countries hidden, and the prose discussed three of the hidden ones.
show_all from DoingEconomics forwards the display with the limit off:
show_all(country_table) # every row, instead of head + ⋮ + tailTwo details matter in its implementation. It defines show for exactly the MIME types DataFrames implements, not a generic show(::IO, ::MIME, ::AllRows) — Quarto picks an output format by attempting show rather than by consulting showable, so a catch-all method advertises text/markdown, gets chosen, and dies inside the forwarded call. And showable delegates to the wrapped table so the choice stays honest. test/runtests.jl pins both.
Missing values
| R | Julia |
|---|---|
NA |
missing |
is.na(x) |
ismissing(x) |
mean(x, na.rm = TRUE) |
mean(skipmissing(x)) |
na.strings = "***" |
missingstring = "***" |
complete.cases(df) |
dropmissing(df) |
The difference that matters: R’s aggregate functions mostly default to propagating NA and offer na.rm to opt out; Julia has no such flag, and you wrap the input in skipmissing instead. Julia’s version is noisier to write and harder to get wrong by accident — you cannot drop missing data without a visible skipmissing at the call site.
Numbers in the data
The GISS temperature file writes values as -.37 rather than -0.37, and codes missing as ***. R’s read.csv handles the leading-dot form because as.numeric does; CSV.jl parses it too. Neither needs special handling, but it is worth knowing the file looks like that before concluding a parse has gone wrong.