Doing Economics in Julia
  • Home
  • Setup
  • R → Julia
  1. Reference
  2. R → Julia
  • Getting started
    • Overview
    • Setup
  • Empirical projects
    • 1. Measuring climate change
    • 2. Data from experiments
    • 3. Measuring a sugar tax
    • 4. Measuring wellbeing
    • 5. Measuring inequality
    • 6. Management practices
    • 7. Supply and demand
    • 8. Cost of unemployment
    • 9. Credit-excluded households
    • 10. Banking systems
    • 11. Willingness to pay
    • 12. Hong Kong cash handout
    • Extra 2. Carbon taxation
  • Reference
    • R → Julia
    • Technical reference

On this page

  • Reading data
  • Subsetting and reshaping
  • Summary statistics
  • Charts
    • The dual-axis chart
  • Soft scope: the one that actually bites
    • let is fix #1 only when you initialise inside it
  • Long tables lose their middle
  • Missing values
  • Numbers in the data
  • View source
  • Report an issue
  1. Reference
  2. R → Julia

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:

  • var is the sample variance in both, dividing by n − 1. R has no option; Julia’s var(x; corrected = false) gives the population form. The book means the sample variance, so the default is right.
  • quantile uses the same default. R’s type = 7 and Julia’s default are the same linear-interpolation rule, so deciles match exactly. R offers nine types and Julia takes alpha/beta parameters; 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 6

The 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 0

Julia’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
end

Inside 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
end

let 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 + ⋮ + tail

Two 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.

Extra 2. Carbon taxation
Technical reference
Source Code
---
title: "R → Julia"
subtitle: "Translating the book's walk-throughs"
engine: markdown
---

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:

- **`var` is the sample variance in both**, dividing by `n − 1`. R has no option; Julia's
  `var(x; corrected = false)` gives the population form. The book means the sample variance,
  so the default is right.
- **`quantile` uses the same default.** R's `type = 7` and Julia's default are the same
  linear-interpolation rule, so deciles match exactly. R offers nine types and Julia takes
  `alpha`/`beta` parameters; 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:

```r
total <- 0
for (i in 1:3) total <- total + i   # total is 6
```

The direct Julia translation silently does not, at top level:

```julia
total = 0
for i in 1:3
    total += i      # warns, and creates a NEW local each iteration
end
total               # still 0
```

Julia'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:

```julia
# 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
end
```

Inside 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:

```julia
df = load()
let keep = df.x .> 0
    df = df[keep, :]       # UndefVarError: `df` not defined in local scope
end
```

`let` 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:

```julia
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:

```julia
show_all(country_table)      # every row, instead of head + ⋮ + tail
```

Two 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.

Code MIT-licensed. Projects and solutions © CORE Econ.

 
  • View source
  • Report an issue

Built with Quarto and Julia.