---
title: "U.S. Gross Output: Nominal, Quantity, and Price"
subtitle: "Levels, growth, breadth, dispersion, and contributions across 71 BEA leaf industries"
author: ""
format:
html:
theme: cosmo
toc: true
toc-location: left
toc-depth: 3
code-fold: true
code-summary: "Show R code"
code-tools: true
df-print: paged
fig-responsive: true
embed-resources: true
execute:
echo: true
warning: false
message: false
error: false
---
## Overview
Gross output (GO) measures production across industries, including intermediate transactions. Nominal gross output is roughly analogous to industry revenues. This report uses three related BEA series: **nominal gross output ($ billions, SAAR)**, **chain-type gross-output quantity indexes (2017 = 100)**, and **chain-type gross-output price indexes (2017 = 100)**.
Each is analyzed separately using the **same 71 non-overlapping industries**, selected and ordered by the `Leaf = 1` flags in the **`LeafTaxonomy` sheet of `IndustryTaxonomy.xlsx`**. The underlying observations come only from `GrossOutput.xlsx`, `GrossOutput-Qty.xlsx`, and `GrossOutput-Price.xlsx`. Data are matched by BEA **line number** rather than by text names or spreadsheet positions. All growth rates are **nonannualized**: quarter-over-quarter (QoQ) is primary; year-over-year (YoY) is supplemental.
Nominal GO industry levels are additive, allowing *exact* percentage-point contributions to growth (apart from BEA rounding versus the published total). Quantity and price indexes are **not additive**. We display **approximate contributions** for the quantity and price sections using lagged nominal GO shares, and explicitly report the difference between their sum and the official aggregate index growth. We do not force a reconciliation.
```{r}
#| label: setup
#| include: false
required_pkgs <- c("readxl", "dplyr", "tidyr", "tibble", "ggplot2", "stringr", "scales", "knitr")
not_installed <- setdiff(required_pkgs, rownames(installed.packages()))
if (length(not_installed)) {
stop("Install these R packages before rendering: ", paste(not_installed, collapse = ", "),
"\nFor example: install.packages(c(",
paste(sprintf('"%s"', not_installed), collapse = ", "), "))")
}
invisible(lapply(required_pkgs, library, character.only = TRUE))
files <- c(
taxonomy = "IndustryTaxonomy.xlsx",
nominal = "GrossOutput.xlsx",
quantity = "GrossOutput-Qty.xlsx",
price = "GrossOutput-Price.xlsx"
)
missing_files <- files[!file.exists(files)]
if (length(missing_files)) {
stop("Place these Excel workbooks next to the QMD before rendering: ",
paste(missing_files, collapse = ", "))
}
positive_col <- "#257C73"
negative_col <- "#B84F4B"
neutral_col <- "#284E71"
fmt_pp <- function(x, digits = 2) sprintf(paste0("%+.", digits, "f pp"), x)
fmt_pct <- function(x, digits = 1) sprintf(paste0("%.", digits, "f%%"), 100 * x)
format_q <- function(d) paste0(format(d, "%Y"), " Q", (as.integer(format(d, "%m")) - 1L) %/% 3L + 1L)
common_theme <- theme_minimal(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
plot.subtitle = element_text(colour = "grey35"),
panel.grid.minor = element_blank(),
legend.position = "bottom")
theme_set(common_theme)
```
## Data construction and checks
The `LeafTaxonomy` worksheet controls **selection, order, and broad-group assignments**. We read *raw levels or indexes* from the three separate BEA `Table` sheets, keyed on the BEA `Line` identifier. The year and quarter labels are reconstructed independently for each workbook. No previously computed filtered sheets or breadth statistics are used.
```{r}
#| label: import-and-taxonomy
# Taxonomy supplies flags and original BEA order, not the observed data.
tax_raw <- readxl::read_excel(
files[["taxonomy"]], sheet = "LeafTaxonomy", col_names = FALSE,
col_types = "text", trim_ws = FALSE, .name_repair = "unique"
)
meta_all <- tibble::tibble(
leaf_flag = suppressWarnings(as.integer(tax_raw[[1]])),
line = suppressWarnings(as.integer(tax_raw[[2]])),
raw_name = tax_raw[[3]]
) |>
dplyr::filter(!is.na(line), !is.na(raw_name)) |>
dplyr::mutate(
industry = stringr::str_trim(raw_name),
indent = nchar(raw_name) - nchar(stringr::str_trim(raw_name, side = "left"))
)
stopifnot(!anyDuplicated(meta_all$line), any(meta_all$industry == "All industries"))
# Parent group determined from indentation in the taxonomy (not any sorting).
current_group <- NA_character_
in_government <- FALSE
groups <- character(nrow(meta_all))
for (j in seq_len(nrow(meta_all))) {
label <- meta_all$industry[j]
if (label == "Government") {
in_government <- TRUE
current_group <- "Government"
} else if (!in_government && meta_all$indent[j] == 4L) {
current_group <- label
}
groups[j] <- current_group
}
meta_all$broad_group <- groups
industry_meta <- meta_all |>
dplyr::filter(leaf_flag == 1L) |>
dplyr::mutate(industry_id = dplyr::row_number()) |>
dplyr::select(industry_id, line, industry, broad_group)
stopifnot(nrow(industry_meta) == 71L, !anyNA(industry_meta$broad_group),
!anyDuplicated(industry_meta$line))
broad_groups <- unique(industry_meta$broad_group)
industry_order <- rev(industry_meta$industry_id)
industry_axis_labels <- stats::setNames(
paste0("[", industry_meta$line, "] ",
stringr::str_wrap(industry_meta$industry, width = 49)),
as.character(industry_meta$industry_id)
)
# Read a BEA 'Table' tab and obtain exactly the same line IDs as in LeafTaxonomy.
read_bea_series <- function(path) {
raw <- readxl::read_excel(path, sheet = "Table", col_names = FALSE,
col_types = "text", trim_ws = FALSE,
.name_repair = "unique")
q_rows <- which(vapply(seq_len(nrow(raw)), function(i) {
v <- unlist(raw[i, , drop = FALSE], use.names = FALSE)
sum(stringr::str_detect(v, "^Q[1-4]$"), na.rm = TRUE) >= 8L
}, logical(1)))
stopifnot(length(q_rows) == 1L)
q_row <- q_rows[1]
q_cols <- which(stringr::str_detect(
unlist(raw[q_row, , drop = FALSE], use.names = FALSE), "^Q[1-4]$"))
q_text <- unlist(raw[q_row, q_cols, drop = FALSE], use.names = FALSE)
year_text <- unlist(raw[q_row - 1L, q_cols, drop = FALSE], use.names = FALSE)
years <- tidyr::fill(tibble::tibble(year = suppressWarnings(as.integer(year_text))),
year, .direction = "down")$year
quarters <- as.integer(stringr::str_remove(q_text, "Q"))
stopifnot(length(q_cols) > 4L, !anyNA(years), all(quarters %in% 1:4))
ds <- as.Date(sprintf("%04d-%02d-01", years, 1L + (quarters - 1L) * 3L))
stopifnot(!anyDuplicated(ds), all(diff(ds) > 0))
body <- seq.int(q_row + 1L, nrow(raw))
source_rows <- tibble::tibble(
row = body,
line = suppressWarnings(as.integer(raw[[1]][body])),
name = stringr::str_trim(raw[[2]][body])
) |>
dplyr::filter(!is.na(line))
stopifnot(!anyDuplicated(source_rows$line))
matching_rows <- match(industry_meta$line, source_rows$line)
stopifnot(!anyNA(matching_rows), 1L %in% source_rows$line)
# Matrix constructed in column-major order, with one source row per line.
full_values <- matrix(
suppressWarnings(as.numeric(unlist(
raw[source_rows$row, q_cols, drop = FALSE], use.names = FALSE))),
nrow = nrow(source_rows), ncol = length(ds)
)
leaf_values <- full_values[matching_rows, , drop = FALSE]
official <- full_values[match(1L, source_rows$line), ]
stopifnot(all(is.finite(leaf_values)), all(leaf_values > 0),
all(is.finite(official)), all(official > 0))
leaf_long <- tidyr::expand_grid(industry_id = industry_meta$industry_id,
date_id = seq_along(ds)) |>
dplyr::mutate(date = ds[date_id],
value = leaf_values[cbind(industry_id, date_id)]) |>
dplyr::left_join(industry_meta, by = "industry_id") |>
dplyr::select(date, industry_id, line, industry, broad_group, value)
list(dates = ds, leaf = leaf_long, official = official,
leaf_values = leaf_values)
}
src_go <- read_bea_series(files[["nominal"]])
src_qty <- read_bea_series(files[["quantity"]])
src_price <- read_bea_series(files[["price"]])
stopifnot(identical(src_go$dates, src_qty$dates),
identical(src_go$dates, src_price$dates))
dates <- src_go$dates
value_matrix <- src_go$leaf_values
industry <- src_go$leaf |>
dplyr::rename(go = value)
# Nominal additivity is checked against the published total but we use the
# leaf sum as analytical denominator so contributions add *exactly*.
aggregate <- industry |>
dplyr::group_by(date) |>
dplyr::summarise(go = sum(go), .groups = "drop") |>
dplyr::arrange(date) |>
dplyr::mutate(
official_go = src_go$official,
reconciliation_bn = go - official_go,
go_lag1 = dplyr::lag(go),
go_lag4 = dplyr::lag(go, 4),
growth_qoq = go / go_lag1 - 1,
growth_yoy = go / go_lag4 - 1
)
industry <- industry |>
dplyr::left_join(aggregate |>
dplyr::select(date, total_go = go, total_lag1 = go_lag1,
total_lag4 = go_lag4), by = "date") |>
dplyr::group_by(industry_id) |>
dplyr::arrange(date, .by_group = TRUE) |>
dplyr::mutate(
go_lag1 = dplyr::lag(go),
go_lag4 = dplyr::lag(go, 4),
growth_qoq = go / go_lag1 - 1,
growth_yoy = go / go_lag4 - 1,
weight_qoq = go_lag1 / total_lag1,
weight_yoy = go_lag4 / total_lag4,
contrib_qoq_pp = 100 * (go - go_lag1) / total_lag1,
contrib_yoy_pp = 100 * (go - go_lag4) / total_lag4
) |>
dplyr::ungroup()
qoq_check <- industry |>
dplyr::group_by(date) |>
dplyr::summarise(sum_contributions_pp = sum(contrib_qoq_pp), .groups = "drop") |>
dplyr::left_join(aggregate |> dplyr::select(date, growth_qoq), by = "date") |>
dplyr::filter(!is.na(growth_qoq)) |>
dplyr::mutate(diff_pp = sum_contributions_pp - 100 * growth_qoq)
yoy_check <- industry |>
dplyr::group_by(date) |>
dplyr::summarise(sum_contributions_pp = sum(contrib_yoy_pp), .groups = "drop") |>
dplyr::left_join(aggregate |> dplyr::select(date, growth_yoy), by = "date") |>
dplyr::filter(!is.na(growth_yoy)) |>
dplyr::mutate(diff_pp = sum_contributions_pp - 100 * growth_yoy)
stopifnot(max(abs(qoq_check$diff_pp)) < 1e-8,
max(abs(yoy_check$diff_pp)) < 1e-8)
latest_date <- max(dates)
latest_label <- format_q(latest_date)
latest_aggregate <- dplyr::filter(aggregate, date == latest_date)
latest_industry <- dplyr::filter(industry, date == latest_date)
```
The source tables agree on the same line numbers and quarters. The analytical sample contains **`r nrow(industry_meta)` industries** in **`r length(dates)` quarters**, from **`r format_q(min(dates))`** through **`r latest_label`**. They belong to **`r length(broad_groups)` broad BEA groups**. The largest absolute difference between the published aggregate and the sum of the 71 rounded industries is **$`r sprintf("%.1f", max(abs(aggregate$reconciliation_bn)))` billion**. Both contribution identities pass their numerical checks.
```{r}
#| label: source-checks
#| tbl-cap: "Source-data and contribution-reconciliation checks"
knitr::kable(tibble::tibble(
Check = c("Leaf industries", "Broad industry groups", "Quarterly observations",
"Missing industry-level GO values", "Maximum absolute level reconciliation ($bn)",
"Maximum absolute QoQ contribution residual (pp)",
"Maximum absolute YoY contribution residual (pp)"),
Result = c(nrow(industry_meta), length(broad_groups), length(dates),
sum(is.na(value_matrix)),
sprintf("%.3f", max(abs(aggregate$reconciliation_bn))),
sprintf("%.10f", max(abs(qoq_check$diff_pp))),
sprintf("%.10f", max(abs(yoy_check$diff_pp))))
))
```
# Part I: Nominal gross output
## 1. Aggregate gross output
We begin with the level of total gross output and its growth. The **level** is measured in trillions of nominal dollars **at an annual rate**; the **growth rates** below are not annualized. Comparing QoQ and YoY helps distinguish near-term momentum from changes over a full year.
```{r}
#| label: plot-aggregate-level
#| fig-width: 11
#| fig-height: 5
#| fig-cap: "Total nominal gross output, seasonally adjusted at annual rates"
ggplot(aggregate, aes(x = date, y = go / 1000)) +
geom_line(color = neutral_col, linewidth = 1.1) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y", expand = expansion(mult = c(.01, .01))) +
scale_y_continuous(labels = scales::label_number(accuracy = 1)) +
labs(title = "Total gross output", subtitle = "Sum of the 71 leaf industries",
x = NULL, y = "Trillions of dollars (SAAR)")
```
```{r}
#| label: plot-aggregate-growth
#| fig-width: 11
#| fig-height: 7
#| fig-cap: "Quarterly and year-over-year growth in total nominal gross output"
aggregate |>
dplyr::select(date, growth_qoq, growth_yoy) |>
tidyr::pivot_longer(-date, names_to = "comparison", values_to = "growth") |>
dplyr::mutate(comparison = factor(comparison,
levels = c("growth_qoq", "growth_yoy"),
labels = c("QoQ: nonannualized", "YoY"))) |>
ggplot(aes(date, 100 * growth)) +
geom_hline(yintercept = 0, color = "grey65") +
geom_line(color = neutral_col, linewidth = .9) +
facet_wrap(~comparison, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(title = "Gross output growth", x = NULL, y = "Percent") +
theme(strip.text = element_text(face = "bold"))
```
**Reading the latest quarter.** Total nominal gross output is **$`r sprintf("%.2f", latest_aggregate$go / 1000)` trillion SAAR** in `r latest_label`; its QoQ growth is **`r fmt_pct(latest_aggregate$growth_qoq, 2)`**, compared with **`r fmt_pct(latest_aggregate$growth_yoy, 2)` YoY**. Since the data are nominal, these movements combine price and quantity changes.
## 2. The 71-industry snapshot
A comparison of industry levels shows where nominal gross output is concentrated. The chart deliberately retains the **source industry order**, rather than ranking industries by size; long charts are intentional so labels remain legible. Some names repeat in the hierarchy (for example, government enterprises), so the source **line number** identifies each industry uniquely.
```{r}
#| label: plot-industry-levels
#| fig-width: 12
#| fig-height: 20
#| fig-cap: "Nominal gross output for every leaf industry, in original BEA order"
latest_industry |>
dplyr::mutate(industry_axis = factor(industry_id, levels = industry_order)) |>
ggplot(aes(industry_axis, go / 1000)) +
geom_col(fill = neutral_col, width = .76) +
coord_flip() +
scale_x_discrete(labels = industry_axis_labels) +
scale_y_continuous(labels = scales::label_number(accuracy = .01)) +
labs(title = paste("Industry gross output:", latest_label),
subtitle = "Original BEA sequence; nominal dollars at annual rates",
x = NULL, y = "Trillions of dollars (SAAR)") +
theme(axis.text.y = element_text(size = 8.3),
panel.grid.major.y = element_blank())
```
Industry size does not tell us whether the industry is currently expanding. The next view puts all 71 **QoQ growth rates** on a common axis without changing their order. The direction of growth is indicated by color rather than rank.
```{r}
#| label: plot-industry-growth
#| fig-width: 12
#| fig-height: 20
#| fig-cap: "QoQ growth by leaf industry, retaining the source order"
latest_industry |>
dplyr::mutate(industry_axis = factor(industry_id, levels = industry_order),
direction = ifelse(growth_qoq >= 0, "Expansion", "Contraction")) |>
ggplot(aes(industry_axis, 100 * growth_qoq, fill = direction)) +
geom_col(width = .76) +
geom_hline(yintercept = 0, color = "grey30", linewidth = .25) +
coord_flip() +
scale_x_discrete(labels = industry_axis_labels) +
scale_fill_manual(values = c(Expansion = positive_col, Contraction = negative_col)) +
labs(title = paste("Industry QoQ growth:", latest_label),
subtitle = "Nonannualized percentage change; original BEA sequence",
x = NULL, y = "QoQ growth (%)", fill = NULL) +
theme(axis.text.y = element_text(size = 8.3),
panel.grid.major.y = element_blank())
```
**Interpretation.** The first chart emphasizes the **size** of each industry; the second emphasizes **current momentum**, giving small industries the same visual prominence as large ones. A separate, weighted breadth measure is therefore important.
## 3. Growth breadth
Breadth asks whether aggregate growth is **widespread** or narrowly based. We calculate these indicators independently for QoQ and YoY changes, using exactly the same 71 leaf industries in every quarter:
- **Equal-weighted breadth:** share of industries with positive growth.
- **Output-weighted breadth:** share of prior-period gross output produced by industries with positive growth. The weights are lagged one quarter for QoQ and four quarters for YoY.
- **Net breadth:** share expanding minus share contracting, ranging from −100% to +100% (flat industries contribute zero to either side).
The output-weighted measure answers a different question from equal-weighted breadth: *how much of the economy*, rather than *how many industries*, is expanding?
```{r}
#| label: calculate-breadth
breadth_for <- function(df, growth_col, weight_col, contribution_col, horizon) {
df |>
dplyr::filter(!is.na(.data[[growth_col]]), !is.na(.data[[weight_col]])) |>
dplyr::group_by(date) |>
dplyr::summarise(
horizon = horizon,
n = dplyr::n(),
n_up = sum(.data[[growth_col]] > 0),
n_down = sum(.data[[growth_col]] < 0),
n_flat = sum(.data[[growth_col]] == 0),
breadth = mean(.data[[growth_col]] > 0),
weighted_breadth = sum(.data[[weight_col]] * (.data[[growth_col]] > 0)) /
sum(.data[[weight_col]]),
net_breadth = mean(.data[[growth_col]] > 0) - mean(.data[[growth_col]] < 0),
median_growth_pp = 100 * stats::median(.data[[growth_col]]),
positive_median_pp = if (any(.data[[growth_col]] > 0))
100 * stats::median(.data[[growth_col]][.data[[growth_col]] > 0]) else NA_real_,
negative_median_pp = if (any(.data[[growth_col]] < 0))
100 * stats::median(.data[[growth_col]][.data[[growth_col]] < 0]) else NA_real_,
sd_growth_pp = 100 * stats::sd(.data[[growth_col]]),
iqr_growth_pp = 100 * stats::IQR(.data[[growth_col]]),
weighted_sd_growth_pp = {
r <- .data[[growth_col]]
w <- .data[[weight_col]]
mu <- stats::weighted.mean(r, w)
100 * sqrt(stats::weighted.mean((r - mu)^2, w))
},
sd_contrib_pp = stats::sd(.data[[contribution_col]]),
gross_positive_contrib_pp = sum(pmax(.data[[contribution_col]], 0)),
gross_negative_contrib_pp = sum(pmin(.data[[contribution_col]], 0)),
top5_share_of_positive = {
positive <- .data[[contribution_col]][.data[[contribution_col]] > 0]
if (length(positive)) sum(sort(positive, decreasing = TRUE)[
seq_len(min(5L, length(positive)))]) / sum(positive) else NA_real_
},
.groups = "drop"
)
}
breadth <- dplyr::bind_rows(
breadth_for(industry, "growth_qoq", "weight_qoq", "contrib_qoq_pp", "QoQ"),
breadth_for(industry, "growth_yoy", "weight_yoy", "contrib_yoy_pp", "YoY")
) |>
dplyr::mutate(horizon = factor(horizon, levels = c("QoQ", "YoY")))
latest_breadth_qoq <- dplyr::filter(breadth, horizon == "QoQ", date == latest_date)
latest_breadth_yoy <- dplyr::filter(breadth, horizon == "YoY", date == latest_date)
```
```{r}
#| label: plot-breadth
#| fig-width: 11
#| fig-height: 8
#| fig-cap: "Equal-weighted and lagged-GO-weighted shares of industries expanding"
breadth |>
dplyr::select(date, horizon, breadth, weighted_breadth) |>
tidyr::pivot_longer(c(breadth, weighted_breadth),
names_to = "measure", values_to = "share") |>
dplyr::mutate(measure = factor(measure,
levels = c("breadth", "weighted_breadth"),
labels = c("Equal-weighted", "GO-weighted"))) |>
ggplot(aes(date, 100 * share, color = measure)) +
geom_hline(yintercept = 50, linetype = "dotted", color = "grey55") +
geom_line(linewidth = .85) +
facet_wrap(~horizon, ncol = 1) +
scale_color_manual(values = c("Equal-weighted" = neutral_col,
"GO-weighted" = positive_col)) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, 25)) +
labs(title = "How widespread is expansion?", x = NULL,
y = "Share with positive growth (%)", color = NULL)
```
```{r}
#| label: plot-net-breadth
#| fig-width: 11
#| fig-height: 6
#| fig-cap: "Net breadth: percentage expanding less percentage contracting"
ggplot(breadth, aes(date, 100 * net_breadth)) +
geom_hline(yintercept = 0, color = "grey55") +
geom_line(color = neutral_col, linewidth = .9) +
facet_wrap(~horizon, ncol = 1) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(title = "Net breadth", subtitle = "Up share minus down share",
x = NULL, y = "Net breadth (percentage points of industries)")
```
**Latest reading.** In `r latest_label`, **`r latest_breadth_qoq$n_up` of `r latest_breadth_qoq$n` industries** expanded QoQ and **`r latest_breadth_qoq$n_down` contracted**. Equal-weighted breadth was **`r fmt_pct(latest_breadth_qoq$breadth)`**, compared with output-weighted breadth of **`r fmt_pct(latest_breadth_qoq$weighted_breadth)`**. YoY, the corresponding breadth measures were **`r fmt_pct(latest_breadth_yoy$breadth)`** and **`r fmt_pct(latest_breadth_yoy$weighted_breadth)`**. Differences between these readings can identify whether expansion is concentrated in large or small industries.
## 4. The growth distribution
Breadth records the **sign** of growth but not its **magnitude**. For example, 60 growing industries might rise very little while a small number of shrinking industries experience steep declines. To complement breadth, examine the unconditional median alongside the **conditional** median among growing and shrinking industries.
```{r}
#| label: plot-conditional-medians
#| fig-width: 11
#| fig-height: 8
#| fig-cap: "Median growth across industries and within the positive- and negative-growth subsets"
breadth |>
dplyr::select(date, horizon, median_growth_pp, positive_median_pp, negative_median_pp) |>
tidyr::pivot_longer(dplyr::ends_with("_pp"),
names_to = "measure", values_to = "growth_pp") |>
dplyr::mutate(measure = factor(measure,
levels = c("positive_median_pp", "median_growth_pp", "negative_median_pp"),
labels = c("Median of expanding industries", "Median of all industries",
"Median of contracting industries"))) |>
ggplot(aes(date, growth_pp, color = measure)) +
geom_hline(yintercept = 0, color = "grey65") +
geom_line(linewidth = .8, na.rm = TRUE) +
facet_wrap(~horizon, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
scale_color_manual(values = c("Median of expanding industries" = positive_col,
"Median of all industries" = neutral_col,
"Median of contracting industries" = negative_col)) +
labs(title = "How large are industry gains and losses?", x = NULL,
y = "Industry growth (%)", color = NULL)
```
The latest-quarter distribution provides a direct snapshot. It treats all industries equally and therefore complements the output-weighted statistics.
```{r}
#| label: plot-latest-distribution
#| fig-width: 10
#| fig-height: 5
#| fig-cap: "Cross-sectional distribution of industry QoQ growth in the latest quarter"
ggplot(latest_industry, aes(100 * growth_qoq)) +
geom_histogram(bins = 22, fill = neutral_col, color = "white") +
geom_vline(xintercept = 0, linetype = "dashed", color = negative_col) +
geom_vline(xintercept = latest_breadth_qoq$median_growth_pp,
linetype = "dotted", color = positive_col, linewidth = .9) +
labs(title = paste("Distribution of industry growth:", latest_label),
subtitle = "Dashed line = zero; dotted line = cross-sectional median",
x = "Nonannualized QoQ growth (%)", y = "Number of industries")
```
**Interpretation.** The median of the expanding industries was **`r sprintf("%.2f%%", latest_breadth_qoq$positive_median_pp)`**, versus **`r sprintf("%.2f%%", latest_breadth_qoq$negative_median_pp)`** among contracting industries. These conditional medians need not combine into aggregate growth; the size of each industry is ignored here.
## 5. Contributions to aggregate growth
Breadth asks *how many* industries are growing; contributions ask *how much each industry changes total growth*. For QoQ contributions, with $GO_t=\sum_i GO_{i,t}$:
$$
\underbrace{100\left(\frac{GO_t}{GO_{t-1}}-1\right)}_{\text{Aggregate QoQ growth (\%)}}
=\sum_i \underbrace{100\frac{GO_{i,t}-GO_{i,t-1}}{GO_{t-1}}}_{\text{Industry contribution (pp)}}.
$$
Equivalently, each contribution is the industry's **lagged output weight multiplied by its own QoQ growth rate** (measured in percent). Contributions can be **positive or negative**. The sum equals aggregate growth exactly when the total is computed from the same leaf series. The analogous YoY calculation compares each series with four quarters earlier.
### Broad groups: stacked contributions over three years
The 71 industries are aggregated to **15 non-overlapping broad BEA groups**, with the groups ordered as they appear in the workbook. The stacked bars separate positive and negative segments around zero, so the *net* contribution is the **positive stack plus the negative stack**, not necessarily the top of the positive stack. The black point marks the actual net aggregate QoQ growth.
```{r}
#| label: group-contributions
contrib_group <- industry |>
dplyr::group_by(date, broad_group) |>
dplyr::summarise(contrib_qoq_pp = sum(contrib_qoq_pp),
contrib_yoy_pp = sum(contrib_yoy_pp), .groups = "drop") |>
dplyr::mutate(broad_group = factor(broad_group, levels = broad_groups))
recent_dates <- tail(dates, 12)
recent_group <- contrib_group |>
dplyr::filter(date %in% recent_dates) |>
dplyr::mutate(quarter_label = factor(format_q(date), levels = format_q(recent_dates)))
recent_aggregate <- aggregate |>
dplyr::filter(date %in% recent_dates) |>
dplyr::mutate(quarter_label = factor(format_q(date), levels = format_q(recent_dates)))
# The broad-group contribution identity must also hold.
check_groups <- contrib_group |>
dplyr::filter(!is.na(contrib_qoq_pp)) |>
dplyr::group_by(date) |>
dplyr::summarise(group_total_pp = sum(contrib_qoq_pp), .groups = "drop") |>
dplyr::left_join(aggregate |> dplyr::select(date, growth_qoq), by = "date")
stopifnot(max(abs(check_groups$group_total_pp - 100 * check_groups$growth_qoq)) < 1e-8)
palette_groups <- stats::setNames(
grDevices::hcl.colors(length(broad_groups), palette = "Dark 3"), broad_groups
)
```
```{r}
#| label: plot-stacked-contributions
#| fig-width: 13
#| fig-height: 8
#| fig-cap: "Contributions to QoQ total growth over the most recent 12 quarters, by BEA broad group"
ggplot(recent_group, aes(quarter_label, contrib_qoq_pp, fill = broad_group)) +
geom_col(width = .72) +
geom_hline(yintercept = 0, color = "grey35", linewidth = .25) +
geom_point(data = recent_aggregate, aes(x = quarter_label, y = 100 * growth_qoq),
inherit.aes = FALSE, color = "black", size = 2.2, shape = 21,
fill = "white", stroke = .8) +
scale_fill_manual(values = palette_groups, drop = FALSE) +
labs(title = "Industry-group contributions to aggregate growth",
subtitle = "Nominal, nonannualized QoQ; white dots = net aggregate growth",
x = NULL, y = "Contribution to QoQ growth (percentage points)",
fill = "BEA industry group") +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom", legend.text = element_text(size = 8),
legend.key.height = grid::unit(0.3, "cm")) +
guides(fill = guide_legend(ncol = 3, byrow = TRUE))
```
**Interpretation.** Contributions combine each group's size with its growth. A large group can contribute meaningfully even with modest percentage growth; a small group can experience extreme growth without moving the aggregate much. Positive and negative contributions partially offset; the white dot gives the *net* result in each quarter.
### All 71 industries: current contributions
The following detail chart retains **every BEA leaf industry in its original order**. The metric is contribution in **percentage points**, not the industry's own percentage growth. Thus a small bar is not evidence that the industry's own growth was weak; it may be small in economic weight.
```{r}
#| label: plot-detail-contributions
#| fig-width: 12
#| fig-height: 20
#| fig-cap: "Latest-quarter contribution to aggregate QoQ growth for all 71 industries"
latest_industry |>
dplyr::mutate(industry_axis = factor(industry_id, levels = industry_order),
direction = ifelse(contrib_qoq_pp >= 0, "Positive", "Negative")) |>
ggplot(aes(industry_axis, contrib_qoq_pp, fill = direction)) +
geom_col(width = .76) +
geom_hline(yintercept = 0, color = "grey30", linewidth = .25) +
coord_flip() +
scale_x_discrete(labels = industry_axis_labels) +
scale_fill_manual(values = c(Positive = positive_col, Negative = negative_col)) +
labs(title = paste("Contributions by leaf industry:", latest_label),
subtitle = "Original BEA order; industry changes as a share of lagged total GO",
x = NULL, y = "QoQ contribution (percentage points)", fill = NULL) +
theme(axis.text.y = element_text(size = 8.3),
panel.grid.major.y = element_blank())
```
### All 71 industries: three-year contribution heatmap
This heatmap preserves industry order vertically and shows the **last 12 quarters** horizontally. It is useful for identifying persistent positive or negative contributors, as well as abrupt reversals. Color intensity measures the magnitude of the percentage-point contribution, not the industry's growth rate.
```{r}
#| label: plot-contribution-heatmap
#| fig-width: 13
#| fig-height: 20
#| fig-cap: "QoQ growth contribution by industry and quarter, with unchanged BEA industry order"
recent_detail <- industry |>
dplyr::filter(date %in% recent_dates) |>
dplyr::mutate(
industry_axis = factor(industry_id, levels = industry_order),
quarter_label = factor(format_q(date), levels = format_q(recent_dates))
)
heat_limit <- max(abs(recent_detail$contrib_qoq_pp), na.rm = TRUE)
ggplot(recent_detail, aes(quarter_label, industry_axis, fill = contrib_qoq_pp)) +
geom_tile(color = "white", linewidth = .15) +
scale_y_discrete(labels = industry_axis_labels) +
scale_fill_gradient2(low = negative_col, mid = "#F6F6F4", high = neutral_col,
midpoint = 0, limits = c(-heat_limit, heat_limit)) +
labs(title = "Contribution history across the 71 industries",
subtitle = "Signed percentage points of aggregate QoQ growth",
x = NULL, y = NULL, fill = "Contribution\n(pp)") +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
axis.text.y = element_text(size = 8.2),
panel.grid = element_blank(), legend.position = "right")
```
## 6. Dispersion of industry growth
Dispersion describes **how differently the industries are performing**, rather than whether the typical industry is expanding. Here we compare:
- **Standard deviation:** how spread out the 71 growth rates are, in percentage points; especially sensitive to extreme observations.
- **Interquartile range (IQR):** 75th percentile minus 25th percentile; more robust to outliers.
- **GO-weighted standard deviation:** uses prior-period gross output weights, emphasizing the behavior of larger industries.
High dispersion can occur in an expansion or in a contraction, and it does not by itself imply weak aggregate activity. All measures use nominal growth, so sector-specific price changes can be a major source of cross-sectional variation.
```{r}
#| label: plot-dispersion
#| fig-width: 11
#| fig-height: 9
#| fig-cap: "Equal-weighted and weighted cross-sectional dispersion of industry growth"
breadth |>
dplyr::select(date, horizon, sd_growth_pp, iqr_growth_pp, weighted_sd_growth_pp) |>
tidyr::pivot_longer(dplyr::ends_with("_pp"),
names_to = "measure", values_to = "dispersion") |>
dplyr::mutate(measure = factor(measure,
levels = c("sd_growth_pp", "iqr_growth_pp", "weighted_sd_growth_pp"),
labels = c("Standard deviation", "Interquartile range", "GO-weighted SD"))) |>
ggplot(aes(date, dispersion, color = measure)) +
geom_line(linewidth = .8) +
facet_wrap(~horizon, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
scale_color_manual(values = c("Standard deviation" = neutral_col,
"Interquartile range" = positive_col,
"GO-weighted SD" = "#A36D32")) +
labs(title = "Dispersion in industry growth rates", x = NULL,
y = "Percentage points", color = NULL)
```
In `r latest_label`, the QoQ cross-sectional standard deviation was **`r sprintf("%.2f", latest_breadth_qoq$sd_growth_pp)` pp**; the IQR was **`r sprintf("%.2f", latest_breadth_qoq$iqr_growth_pp)` pp**, and the lagged-GO-weighted standard deviation was **`r sprintf("%.2f", latest_breadth_qoq$weighted_sd_growth_pp)` pp**. A large difference between the equal-weighted and GO-weighted SDs indicates that the greatest volatility is concentrated among either smaller or larger industries.
## 7. Dispersion and concentration of contributions
Growth-rate dispersion treats industries equally, but **contribution dispersion** incorporates industry size. The standard deviation of individual percentage-point contributions is therefore a distinct concept: dispersion in how much industries matter to aggregate change.
A companion concentration measure asks: **What fraction of all positive contributions came from the five largest positive contributors?** This requires ranking contributions *within the calculation*, but it does not reorder any industry chart. A high value means aggregate expansion relies heavily on a small subset of industries. The denominator is the **sum of positive contributions**, not net aggregate growth, because negative contributions can offset positive ones.
```{r}
#| label: plot-contribution-dispersion
#| fig-width: 11
#| fig-height: 6
#| fig-cap: "Cross-sectional standard deviation of individual contributions to aggregate growth"
ggplot(breadth, aes(date, sd_contrib_pp)) +
geom_line(color = neutral_col, linewidth = .9) +
facet_wrap(~horizon, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(title = "Dispersion of industry growth contributions",
subtitle = "Standard deviation across the 71 industry contributions",
x = NULL, y = "Percentage points of total GO growth")
```
```{r}
#| label: plot-concentration
#| fig-width: 11
#| fig-height: 6
#| fig-cap: "Share of positive aggregate growth contributions provided by the five largest positive contributors"
ggplot(breadth, aes(date, 100 * top5_share_of_positive)) +
geom_line(color = neutral_col, linewidth = .9, na.rm = TRUE) +
facet_wrap(~horizon, ncol = 1) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
scale_y_continuous(limits = c(0, 100), labels = scales::label_number(suffix = "%")) +
labs(title = "Concentration among positive contributors",
subtitle = "Top five positive contributions / sum of all positive contributions",
x = NULL, y = "Share (%)")
```
**Interpretation.** The top five positive contributors accounted for **`r fmt_pct(latest_breadth_qoq$top5_share_of_positive)`** of all positive QoQ contributions in `r latest_label`. This is a **concentration statistic**, not a direct measure of whether aggregate growth was strong or weak. It should be interpreted alongside breadth and the total positive and negative contributions.
## 8. Latest-quarter summary
The table gathers the principal measurements into one place. It is intended as a starting dashboard rather than a model of the determinants of industry growth.
```{r}
#| label: latest-dashboard
#| tbl-cap: "Latest-quarter aggregate, breadth, dispersion and concentration statistics"
last_rows <- breadth |>
dplyr::filter(date == latest_date) |>
dplyr::arrange(horizon)
summary_table <- tibble::tibble(
Metric = c("Aggregate growth", "Expanding industries", "Equal-weighted breadth",
"GO-weighted breadth", "Net breadth", "Median industry growth",
"Industry-growth SD", "Industry-growth IQR", "Weighted industry-growth SD",
"Contribution SD", "Top-five positive-contribution share"),
QoQ = c(fmt_pct(latest_aggregate$growth_qoq, 2),
sprintf("%d / %d", latest_breadth_qoq$n_up, latest_breadth_qoq$n),
fmt_pct(latest_breadth_qoq$breadth),
fmt_pct(latest_breadth_qoq$weighted_breadth),
sprintf("%+.1f pp", 100 * latest_breadth_qoq$net_breadth),
sprintf("%.2f%%", latest_breadth_qoq$median_growth_pp),
sprintf("%.2f pp", latest_breadth_qoq$sd_growth_pp),
sprintf("%.2f pp", latest_breadth_qoq$iqr_growth_pp),
sprintf("%.2f pp", latest_breadth_qoq$weighted_sd_growth_pp),
sprintf("%.3f pp", latest_breadth_qoq$sd_contrib_pp),
fmt_pct(latest_breadth_qoq$top5_share_of_positive)),
YoY = c(fmt_pct(latest_aggregate$growth_yoy, 2),
sprintf("%d / %d", latest_breadth_yoy$n_up, latest_breadth_yoy$n),
fmt_pct(latest_breadth_yoy$breadth),
fmt_pct(latest_breadth_yoy$weighted_breadth),
sprintf("%+.1f pp", 100 * latest_breadth_yoy$net_breadth),
sprintf("%.2f%%", latest_breadth_yoy$median_growth_pp),
sprintf("%.2f pp", latest_breadth_yoy$sd_growth_pp),
sprintf("%.2f pp", latest_breadth_yoy$iqr_growth_pp),
sprintf("%.2f pp", latest_breadth_yoy$weighted_sd_growth_pp),
sprintf("%.3f pp", latest_breadth_yoy$sd_contrib_pp),
fmt_pct(latest_breadth_yoy$top5_share_of_positive))
)
knitr::kable(summary_table, caption = paste("As of", latest_label))
```
# Part II: Gross-output quantity indexes
Unlike nominal gross output, **chain-type quantity indexes** describe real production volume relative to 2017 (= 100). An index value of 120 means that the quantity measure is 20% above its own 2017 base. Index levels cannot be added across industries, and a high industry index does **not** mean the industry is economically larger.
The next two parts use the same leaf-industry taxonomy, the same industry order, and the same QoQ/YoY breadth and dispersion formulas as the nominal section. For weighting, we reuse **lagged nominal GO shares** from `GrossOutput.xlsx`, not the index levels themselves.
```{r}
#| label: build-quantity-price-panels
# Nonadditive chain indexes: lagged-NOMINAL-weighted industry growth is an
# approximation to aggregate index growth, not an exact BEA contribution.
build_index_panel <- function(source) {
idx <- source$leaf |>
dplyr::rename(index = value) |>
dplyr::left_join(
industry |>
dplyr::select(date, industry_id, weight_qoq, weight_yoy),
by = c("date", "industry_id")
) |>
dplyr::group_by(industry_id) |>
dplyr::arrange(date, .by_group = TRUE) |>
dplyr::mutate(
index_lag1 = dplyr::lag(index),
index_lag4 = dplyr::lag(index, 4),
growth_qoq = index / index_lag1 - 1,
growth_yoy = index / index_lag4 - 1,
contrib_qoq_pp = 100 * weight_qoq * growth_qoq,
contrib_yoy_pp = 100 * weight_yoy * growth_yoy
) |>
dplyr::ungroup()
agg <- tibble::tibble(date = source$dates, index = source$official) |>
dplyr::mutate(
growth_qoq = index / dplyr::lag(index) - 1,
growth_yoy = index / dplyr::lag(index, 4) - 1
)
checks <- idx |>
dplyr::group_by(date) |>
dplyr::summarise(
approx_qoq_pp = sum(contrib_qoq_pp, na.rm = TRUE),
approx_yoy_pp = sum(contrib_yoy_pp, na.rm = TRUE),
.groups = "drop"
) |>
dplyr::left_join(agg, by = "date") |>
dplyr::mutate(
residual_qoq_pp = 100 * growth_qoq - approx_qoq_pp,
residual_yoy_pp = 100 * growth_yoy - approx_yoy_pp
)
b <- dplyr::bind_rows(
breadth_for(idx, "growth_qoq", "weight_qoq", "contrib_qoq_pp", "QoQ"),
breadth_for(idx, "growth_yoy", "weight_yoy", "contrib_yoy_pp", "YoY")
) |>
dplyr::mutate(horizon = factor(horizon, levels = c("QoQ", "YoY")))
list(industry = idx, aggregate = agg, checks = checks, breadth = b)
}
qty <- build_index_panel(src_qty)
price <- build_index_panel(src_price)
stopifnot(nrow(qty$industry) == 71L * length(dates),
nrow(price$industry) == 71L * length(dates))
# Shared chart functions ensure the analysis is identical across both indexes.
plot_index_level <- function(panel, label) {
ggplot(panel$aggregate, aes(date, index)) +
geom_line(color = neutral_col, linewidth = 1.1) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(title = paste("Aggregate gross-output", tolower(label), "index"),
subtitle = "BEA chain-type index; 2017 = 100",
x = NULL, y = "Index (2017 = 100)")
}
plot_index_growth <- function(panel, label) {
panel$aggregate |>
dplyr::select(date, growth_qoq, growth_yoy) |>
tidyr::pivot_longer(-date, names_to = "horizon", values_to = "growth") |>
dplyr::mutate(horizon = factor(horizon,
levels = c("growth_qoq", "growth_yoy"),
labels = c("QoQ: nonannualized", "YoY"))) |>
ggplot(aes(date, 100 * growth)) +
geom_hline(yintercept = 0, color = "grey60") +
geom_line(color = neutral_col, linewidth = .9) +
facet_wrap(~ horizon, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(title = paste("Aggregate", tolower(label), "growth"),
x = NULL, y = "Percent")
}
plot_index_industries <- function(panel, label, metric = "level") {
df <- panel$industry |>
dplyr::filter(date == latest_date) |>
dplyr::mutate(industry_axis = factor(industry_id, levels = industry_order))
if (metric == "level") {
ggplot(df, aes(industry_axis, index)) +
geom_col(fill = neutral_col, width = .76) +
coord_flip() +
scale_x_discrete(labels = industry_axis_labels) +
labs(title = paste(label, "index by industry:", latest_label),
subtitle = "2017 = 100; original BEA order (not industry size)",
x = NULL, y = "2017 = 100") +
theme(axis.text.y = element_text(size = 8.3),
panel.grid.major.y = element_blank())
} else {
df <- df |>
dplyr::mutate(direction = ifelse(growth_qoq >= 0, "Expansion", "Contraction"))
ggplot(df, aes(industry_axis, 100 * growth_qoq, fill = direction)) +
geom_col(width = .76) +
geom_hline(yintercept = 0, color = "grey35", linewidth = .25) +
coord_flip() +
scale_x_discrete(labels = industry_axis_labels) +
scale_fill_manual(values = c(Expansion = positive_col,
Contraction = negative_col)) +
labs(title = paste(label, "QoQ growth by industry:", latest_label),
subtitle = "Nonannualized; original BEA order",
x = NULL, y = "QoQ growth (%)", fill = NULL) +
theme(axis.text.y = element_text(size = 8.3),
panel.grid.major.y = element_blank())
}
}
plot_index_breadth <- function(panel, label) {
panel$breadth |>
dplyr::select(date, horizon, breadth, weighted_breadth) |>
tidyr::pivot_longer(c(breadth, weighted_breadth),
names_to = "measure", values_to = "share") |>
dplyr::mutate(measure = factor(measure,
levels = c("breadth", "weighted_breadth"),
labels = c("Equal-weighted", "Nominal-GO-weighted"))) |>
ggplot(aes(date, 100 * share, color = measure)) +
geom_hline(yintercept = 50, linetype = "dotted", color = "grey55") +
geom_line(linewidth = .8) +
facet_wrap(~horizon, ncol = 1) +
scale_color_manual(values = c("Equal-weighted" = neutral_col,
"Nominal-GO-weighted" = positive_col)) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, 25)) +
labs(title = paste(label, "growth breadth"), x = NULL,
y = "Share of industries with positive growth (%)", color = NULL)
}
plot_index_net_breadth <- function(panel, label) {
ggplot(panel$breadth, aes(date, 100 * net_breadth)) +
geom_hline(yintercept = 0, color = "grey55") +
geom_line(color = neutral_col, linewidth = .9) +
facet_wrap(~horizon, ncol = 1) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(title = paste(label, "net breadth"),
subtitle = "Share expanding minus share contracting",
x = NULL, y = "Percentage points of industries")
}
plot_index_medians <- function(panel, label) {
panel$breadth |>
dplyr::select(date, horizon, median_growth_pp,
positive_median_pp, negative_median_pp) |>
tidyr::pivot_longer(dplyr::ends_with("_pp"),
names_to = "series", values_to = "growth_pp") |>
dplyr::mutate(series = factor(series,
levels = c("positive_median_pp", "median_growth_pp", "negative_median_pp"),
labels = c("Median if positive", "Overall median", "Median if negative"))) |>
ggplot(aes(date, growth_pp, color = series)) +
geom_hline(yintercept = 0, color = "grey60") +
geom_line(linewidth = .75) +
facet_wrap(~horizon, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
scale_color_manual(values = c("Median if positive" = positive_col,
"Overall median" = neutral_col,
"Median if negative" = negative_col)) +
labs(title = paste(label, "growth: cross-sectional medians"),
x = NULL, y = "Industry growth (%)", color = NULL)
}
plot_index_histogram <- function(panel, label) {
panel$industry |>
dplyr::filter(date == latest_date) |>
ggplot(aes(100 * growth_qoq)) +
geom_histogram(bins = 22, fill = neutral_col, color = "white") +
geom_vline(xintercept = 0, linetype = "dashed", color = negative_col) +
labs(title = paste(label, "growth distribution:", latest_label),
x = "Industry QoQ growth (%)", y = "Number of industries")
}
plot_index_groups <- function(panel, label) {
grouped <- panel$industry |>
dplyr::filter(date %in% recent_dates) |>
dplyr::group_by(date, broad_group) |>
dplyr::summarise(contrib_qoq_pp = sum(contrib_qoq_pp), .groups = "drop") |>
dplyr::mutate(
broad_group = factor(broad_group, levels = broad_groups),
quarter_label = factor(format_q(date), levels = format_q(recent_dates))
)
actual <- panel$aggregate |>
dplyr::filter(date %in% recent_dates) |>
dplyr::mutate(quarter_label = factor(format_q(date), levels = format_q(recent_dates)))
ggplot(grouped, aes(quarter_label, contrib_qoq_pp, fill = broad_group)) +
geom_col(width = .72) +
geom_hline(yintercept = 0, color = "grey35", linewidth = .25) +
geom_point(data = actual,
aes(x = quarter_label, y = 100 * growth_qoq),
inherit.aes = FALSE, color = "black", shape = 21,
fill = "white", stroke = .8, size = 2.2) +
scale_fill_manual(values = palette_groups, drop = FALSE) +
labs(title = paste("Approximate", tolower(label), "growth contributions"),
subtitle = "Lagged nominal-GO weights x industry index growth; white dots = official aggregate growth",
x = NULL, y = "Approx. QoQ contribution (pp)", fill = "BEA industry group") +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
legend.position = "bottom", legend.text = element_text(size = 8),
legend.key.height = grid::unit(.3, "cm")) +
guides(fill = guide_legend(ncol = 3, byrow = TRUE))
}
plot_index_detail_contributions <- function(panel, label) {
panel$industry |>
dplyr::filter(date == latest_date) |>
dplyr::mutate(
industry_axis = factor(industry_id, levels = industry_order),
direction = ifelse(contrib_qoq_pp >= 0, "Positive", "Negative")
) |>
ggplot(aes(industry_axis, contrib_qoq_pp, fill = direction)) +
geom_col(width = .76) +
coord_flip() +
scale_x_discrete(labels = industry_axis_labels) +
scale_fill_manual(values = c(Positive = positive_col, Negative = negative_col)) +
labs(title = paste("Approximate", tolower(label), "contributions:", latest_label),
subtitle = "Original BEA order; lagged nominal shares x index growth",
x = NULL, y = "Approx. QoQ contribution (pp)", fill = NULL) +
theme(axis.text.y = element_text(size = 8.3),
panel.grid.major.y = element_blank())
}
plot_index_contribution_heatmap <- function(panel, label) {
df <- panel$industry |>
dplyr::filter(date %in% recent_dates) |>
dplyr::mutate(
industry_axis = factor(industry_id, levels = industry_order),
quarter_label = factor(format_q(date), levels = format_q(recent_dates))
)
lim <- max(abs(df$contrib_qoq_pp), na.rm = TRUE)
ggplot(df, aes(quarter_label, industry_axis, fill = contrib_qoq_pp)) +
geom_tile(color = "white", linewidth = .15) +
scale_y_discrete(labels = industry_axis_labels) +
scale_fill_gradient2(low = negative_col, mid = "#F6F6F4", high = neutral_col,
midpoint = 0, limits = c(-lim, lim)) +
labs(title = paste(label, "contribution history: 71 industries"),
subtitle = "Approximate QoQ percentage-point contributions over 12 quarters",
x = NULL, y = NULL, fill = "Approx. pp") +
theme(axis.text.x = element_text(angle = 45, hjust = 1),
axis.text.y = element_text(size = 8.2),
panel.grid = element_blank(), legend.position = "right")
}
plot_index_dispersion <- function(panel, label) {
panel$breadth |>
dplyr::select(date, horizon, sd_growth_pp, iqr_growth_pp,
weighted_sd_growth_pp) |>
tidyr::pivot_longer(dplyr::ends_with("_pp"),
names_to = "measure", values_to = "dispersion") |>
dplyr::mutate(measure = factor(measure,
levels = c("sd_growth_pp", "iqr_growth_pp", "weighted_sd_growth_pp"),
labels = c("Standard deviation", "Interquartile range", "Nominal-GO-weighted SD"))) |>
ggplot(aes(date, dispersion, color = measure)) +
geom_line(linewidth = .8) +
facet_wrap(~horizon, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
scale_color_manual(values = c("Standard deviation" = neutral_col,
"Interquartile range" = positive_col,
"Nominal-GO-weighted SD" = "#A36D32")) +
labs(title = paste(label, "growth dispersion"), x = NULL,
y = "Percentage points", color = NULL)
}
plot_index_contribution_dispersion <- function(panel, label) {
ggplot(panel$breadth, aes(date, sd_contrib_pp)) +
geom_line(color = neutral_col, linewidth = .9) +
facet_wrap(~horizon, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(title = paste(label, ": dispersion of approximate contributions"),
x = NULL, y = "Approximate percentage points")
}
plot_index_concentration <- function(panel, label) {
ggplot(panel$breadth, aes(date, 100 * top5_share_of_positive)) +
geom_line(color = neutral_col, linewidth = .9, na.rm = TRUE) +
facet_wrap(~horizon, ncol = 1) +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
scale_y_continuous(limits = c(0, 100), breaks = seq(0, 100, 25)) +
labs(title = paste(label, "approximate-contribution concentration"),
subtitle = "Five largest positive contributions / all positive contributions",
x = NULL, y = "Share (%)")
}
plot_index_reconciliation <- function(panel, label) {
panel$checks |>
dplyr::select(date, residual_qoq_pp, residual_yoy_pp) |>
tidyr::pivot_longer(-date, names_to = "horizon", values_to = "difference_pp") |>
dplyr::mutate(horizon = factor(horizon,
levels = c("residual_qoq_pp", "residual_yoy_pp"),
labels = c("QoQ", "YoY"))) |>
ggplot(aes(date, difference_pp)) +
geom_hline(yintercept = 0, color = "grey55") +
geom_line(color = neutral_col, linewidth = .85) +
facet_wrap(~horizon, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(title = paste(label, "index: approximation residual"),
subtitle = "Published aggregate growth minus summed nominal-share-weighted industry growth",
x = NULL, y = "Percentage points")
}
index_summary_table <- function(panel) {
bq <- dplyr::filter(panel$breadth, date == latest_date, horizon == "QoQ")
by <- dplyr::filter(panel$breadth, date == latest_date, horizon == "YoY")
a <- dplyr::filter(panel$aggregate, date == latest_date)
r <- dplyr::filter(panel$checks, date == latest_date)
tibble::tibble(
Metric = c("Aggregate index (2017 = 100)", "Aggregate growth",
"Industries expanding", "Equal-weighted breadth", "Nominal-GO-weighted breadth",
"Net breadth", "Median industry growth", "Industry-growth SD", "Industry-growth IQR",
"Nominal-GO-weighted industry-growth SD", "Approximate contribution SD",
"Top-five positive approximate-contribution share",
"Sum of approximate industry contributions", "Published minus approximate growth"),
QoQ = c(sprintf("%.3f", a$index), fmt_pct(a$growth_qoq, 2),
sprintf("%d / %d", bq$n_up, bq$n), fmt_pct(bq$breadth),
fmt_pct(bq$weighted_breadth), sprintf("%+.1f pp", 100 * bq$net_breadth),
sprintf("%.2f%%", bq$median_growth_pp), sprintf("%.2f pp", bq$sd_growth_pp),
sprintf("%.2f pp", bq$iqr_growth_pp), sprintf("%.2f pp", bq$weighted_sd_growth_pp),
sprintf("%.3f pp", bq$sd_contrib_pp), fmt_pct(bq$top5_share_of_positive),
sprintf("%+.3f pp", r$approx_qoq_pp), sprintf("%+.3f pp", r$residual_qoq_pp)),
YoY = c(sprintf("%.3f", a$index), fmt_pct(a$growth_yoy, 2),
sprintf("%d / %d", by$n_up, by$n), fmt_pct(by$breadth),
fmt_pct(by$weighted_breadth), sprintf("%+.1f pp", 100 * by$net_breadth),
sprintf("%.2f%%", by$median_growth_pp), sprintf("%.2f pp", by$sd_growth_pp),
sprintf("%.2f pp", by$iqr_growth_pp), sprintf("%.2f pp", by$weighted_sd_growth_pp),
sprintf("%.3f pp", by$sd_contrib_pp), fmt_pct(by$top5_share_of_positive),
sprintf("%+.3f pp", r$approx_yoy_pp), sprintf("%+.3f pp", r$residual_yoy_pp))
)
}
```
## 1. Aggregate quantity index and growth
The aggregate index describes the evolution of real gross-output quantities, while nonannualized QoQ and YoY changes show near-term and annual momentum.
```{r}
#| label: qty-aggregate-level
#| fig-width: 11
#| fig-height: 5
#| fig-cap: "Aggregate BEA gross-output quantity index"
plot_index_level(qty, "Quantity")
```
```{r}
#| label: qty-aggregate-growth
#| fig-width: 11
#| fig-height: 7
#| fig-cap: "Aggregate gross-output quantity growth: QoQ and YoY"
plot_index_growth(qty, "Quantity")
```
**Latest reading.** In `r latest_label`, the published aggregate quantity index grew **`r fmt_pct(tail(qty$aggregate$growth_qoq, 1), 2)` QoQ** and **`r fmt_pct(tail(qty$aggregate$growth_yoy, 1), 2)` YoY**.
## 2. All 71 industries: current index and growth
The level chart retains the original BEA order, but **an index level is not a measure of an industry's size**: every index has the same 2017 base. The QoQ chart is the more directly comparable measure of current industry momentum.
```{r}
#| label: qty-industry-levels
#| fig-width: 12
#| fig-height: 20
#| fig-cap: "Gross-output quantity indexes for all 71 industries, original BEA order"
plot_index_industries(qty, "Quantity", "level")
```
```{r}
#| label: qty-industry-growth
#| fig-width: 12
#| fig-height: 20
#| fig-cap: "QoQ gross-output quantity growth by industry, original BEA order"
plot_index_industries(qty, "Quantity", "growth")
```
## 3. Growth breadth
Equal-weighted breadth counts industries with positive index growth. Weighted breadth sums the *prior-period nominal GO shares* of those growing industries. Net breadth subtracts the share contracting from the share growing. Both QoQ and YoY horizons are shown.
```{r}
#| label: qty-breadth
#| fig-width: 11
#| fig-height: 8
#| fig-cap: "Equal-weighted and nominal-output-weighted quantity growth breadth"
plot_index_breadth(qty, "Quantity")
```
```{r}
#| label: qty-net-breadth
#| fig-width: 11
#| fig-height: 6
#| fig-cap: "Net breadth of quantity growth"
plot_index_net_breadth(qty, "Quantity")
```
**Latest breadth.** `r dplyr::filter(qty$breadth, date == latest_date, horizon == "QoQ")$n_up` of 71 industries grew QoQ, and output-weighted breadth was **`r fmt_pct(dplyr::filter(qty$breadth, date == latest_date, horizon == "QoQ")$weighted_breadth)`**.
## 4. Cross-sectional growth distribution
Breadth alone loses magnitude information. Conditional medians describe typical growth **among growing industries** and **among shrinking industries**; the histogram shows whether the latest distribution is skewed or dominated by outliers.
```{r}
#| label: qty-conditional-medians
#| fig-width: 11
#| fig-height: 8
#| fig-cap: "Conditional and unconditional median quantity growth"
plot_index_medians(qty, "Quantity")
```
```{r}
#| label: qty-latest-histogram
#| fig-width: 11
#| fig-height: 5
#| fig-cap: "Latest-quarter cross-sectional quantity growth distribution"
plot_index_histogram(qty, "Quantity")
```
## 5. Approximate industry contributions (last three years)
For an industry $i$, we compute an **approximate QoQ influence** in percentage points:
$$
\widetilde{c}_{i,t}^{quantity}
=100\left(\frac{GO^{\mathrm{nom}}_{i,t-1}}{\sum_j GO^{\mathrm{nom}}_{j,t-1}}\right)
\left(\frac{I_{i,t}}{I_{i,t-1}}-1\right).
$$
$GO^{\mathrm{nom}}$ denotes nominal gross output and $I$ the industry's chain-type quantity index. The 15 broad-group stacked bars and the 71-leaf views are **diagnostics**, not an exact chain-weighted BEA decomposition. Positive and negative bars stack separately; the **white dots** represent growth in the published aggregate index, which need not equal the bar sums.
```{r}
#| label: qty-stacked-groups
#| fig-width: 13
#| fig-height: 8
#| fig-cap: "Approximate industry-group contributions to quantity growth, last 12 quarters"
plot_index_groups(qty, "Quantity")
```
```{r}
#| label: qty-detailed-contributions
#| fig-width: 12
#| fig-height: 20
#| fig-cap: "All 71 approximate quantity contributions, original BEA order"
plot_index_detail_contributions(qty, "Quantity")
```
```{r}
#| label: qty-contribution-heatmap
#| fig-width: 13
#| fig-height: 20
#| fig-cap: "Approximate contribution history for 71 industries and 12 quarters"
plot_index_contribution_heatmap(qty, "Quantity")
```
The next chart shows the **published aggregate growth minus the sum of approximate contributions** (QoQ and YoY). This residual is the appropriate warning against treating the weighted calculations as an exact accounting identity.
```{r}
#| label: qty-approximation-residual
#| fig-width: 11
#| fig-height: 7
#| fig-cap: "Residual between published quantity index growth and approximate industry contributions"
plot_index_reconciliation(qty, "Quantity")
```
**Latest-quarter residual:** **`r sprintf("%+.4f", tail(qty$checks$residual_qoq_pp, 1))` percentage points** for QoQ growth. This can vary across history, especially when industry relative prices and weights move quickly.
## 6. Dispersion of industry growth
We compare the cross-sectional standard deviation, IQR and nominal-GO-weighted standard deviation of quantity growth. A high reading indicates more disagreement across industries, not necessarily high aggregate growth.
```{r}
#| label: qty-growth-dispersion
#| fig-width: 11
#| fig-height: 9
#| fig-cap: "Cross-sectional dispersion of gross-output quantity growth"
plot_index_dispersion(qty, "Quantity")
```
## 7. Dispersion and concentration of approximate contributions
Contribution dispersion combines differences in growth with differences in economic size. The second chart tracks the share of **all positive approximate contributions** attributed to the five largest positive contributors. Rankings are calculated for the concentration statistic only; plotted industry order remains unchanged.
```{r}
#| label: qty-contribution-dispersion
#| fig-width: 11
#| fig-height: 6
#| fig-cap: "Dispersion of approximate quantity growth contributions"
plot_index_contribution_dispersion(qty, "Quantity")
```
```{r}
#| label: qty-contribution-concentration
#| fig-width: 11
#| fig-height: 6
#| fig-cap: "Top-five share of positive approximate quantity contributions"
plot_index_concentration(qty, "Quantity")
```
## 8. Latest-quarter dashboard
```{r}
#| label: qty-latest-dashboard
#| tbl-cap: "Summary of quantity index growth, breadth, dispersion, and approximate contributions"
knitr::kable(index_summary_table(qty), caption = paste("As of", latest_label))
```
# Part III: Gross-output price indexes
BEA's **chain-type price indexes** measure industry gross-output price changes, normalized to 2017 = 100. These indexes are not dollar amounts or additive values. The analysis below repeats the quantity section for **price growth and inflation breadth**, using the same 71 leaf industries and lagged nominal-GO weights.
## 1. Aggregate price index and growth
The aggregate index describes the evolution of gross-output prices, while nonannualized QoQ and YoY changes show near-term and annual momentum.
```{r}
#| label: price-aggregate-level
#| fig-width: 11
#| fig-height: 5
#| fig-cap: "Aggregate BEA gross-output price index"
plot_index_level(price, "Price")
```
```{r}
#| label: price-aggregate-growth
#| fig-width: 11
#| fig-height: 7
#| fig-cap: "Aggregate gross-output price growth: QoQ and YoY"
plot_index_growth(price, "Price")
```
**Latest reading.** In `r latest_label`, the published aggregate price index grew **`r fmt_pct(tail(price$aggregate$growth_qoq, 1), 2)` QoQ** and **`r fmt_pct(tail(price$aggregate$growth_yoy, 1), 2)` YoY**.
## 2. All 71 industries: current index and growth
The level chart retains the original BEA order, but **an index level is not a measure of an industry's size**: every index has the same 2017 base. The QoQ chart is the more directly comparable measure of current industry momentum.
```{r}
#| label: price-industry-levels
#| fig-width: 12
#| fig-height: 20
#| fig-cap: "Gross-output price indexes for all 71 industries, original BEA order"
plot_index_industries(price, "Price", "level")
```
```{r}
#| label: price-industry-growth
#| fig-width: 12
#| fig-height: 20
#| fig-cap: "QoQ gross-output price growth by industry, original BEA order"
plot_index_industries(price, "Price", "growth")
```
## 3. Growth breadth
Equal-weighted breadth counts industries with positive index growth. Weighted breadth sums the *prior-period nominal GO shares* of those growing industries. Net breadth subtracts the share contracting from the share growing. Both QoQ and YoY horizons are shown.
```{r}
#| label: price-breadth
#| fig-width: 11
#| fig-height: 8
#| fig-cap: "Equal-weighted and nominal-output-weighted price growth breadth"
plot_index_breadth(price, "Price")
```
```{r}
#| label: price-net-breadth
#| fig-width: 11
#| fig-height: 6
#| fig-cap: "Net breadth of price growth"
plot_index_net_breadth(price, "Price")
```
**Latest breadth.** `r dplyr::filter(price$breadth, date == latest_date, horizon == "QoQ")$n_up` of 71 industries grew QoQ, and output-weighted breadth was **`r fmt_pct(dplyr::filter(price$breadth, date == latest_date, horizon == "QoQ")$weighted_breadth)`**.
## 4. Cross-sectional growth distribution
Breadth alone loses magnitude information. Conditional medians describe typical growth **among growing industries** and **among shrinking industries**; the histogram shows whether the latest distribution is skewed or dominated by outliers.
```{r}
#| label: price-conditional-medians
#| fig-width: 11
#| fig-height: 8
#| fig-cap: "Conditional and unconditional median price growth"
plot_index_medians(price, "Price")
```
```{r}
#| label: price-latest-histogram
#| fig-width: 11
#| fig-height: 5
#| fig-cap: "Latest-quarter cross-sectional price growth distribution"
plot_index_histogram(price, "Price")
```
## 5. Approximate industry contributions (last three years)
For an industry $i$, we compute an **approximate QoQ influence** in percentage points:
$$
\widetilde{c}_{i,t}^{price}
=100\left(\frac{GO^{\mathrm{nom}}_{i,t-1}}{\sum_j GO^{\mathrm{nom}}_{j,t-1}}\right)
\left(\frac{I_{i,t}}{I_{i,t-1}}-1\right).
$$
$GO^{\mathrm{nom}}$ denotes nominal gross output and $I$ the industry's chain-type price index. The 15 broad-group stacked bars and the 71-leaf views are **diagnostics**, not an exact chain-weighted BEA decomposition. Positive and negative bars stack separately; the **white dots** represent growth in the published aggregate index, which need not equal the bar sums.
```{r}
#| label: price-stacked-groups
#| fig-width: 13
#| fig-height: 8
#| fig-cap: "Approximate industry-group contributions to price growth, last 12 quarters"
plot_index_groups(price, "Price")
```
```{r}
#| label: price-detailed-contributions
#| fig-width: 12
#| fig-height: 20
#| fig-cap: "All 71 approximate price contributions, original BEA order"
plot_index_detail_contributions(price, "Price")
```
```{r}
#| label: price-contribution-heatmap
#| fig-width: 13
#| fig-height: 20
#| fig-cap: "Approximate contribution history for 71 industries and 12 quarters"
plot_index_contribution_heatmap(price, "Price")
```
The next chart shows the **published aggregate growth minus the sum of approximate contributions** (QoQ and YoY). This residual is the appropriate warning against treating the weighted calculations as an exact accounting identity.
```{r}
#| label: price-approximation-residual
#| fig-width: 11
#| fig-height: 7
#| fig-cap: "Residual between published price index growth and approximate industry contributions"
plot_index_reconciliation(price, "Price")
```
**Latest-quarter residual:** **`r sprintf("%+.4f", tail(price$checks$residual_qoq_pp, 1))` percentage points** for QoQ growth. This can vary across history, especially when industry relative prices and weights move quickly.
## 6. Dispersion of industry growth
We compare the cross-sectional standard deviation, IQR and nominal-GO-weighted standard deviation of price growth. A high reading indicates more disagreement across industries, not necessarily high aggregate growth.
```{r}
#| label: price-growth-dispersion
#| fig-width: 11
#| fig-height: 9
#| fig-cap: "Cross-sectional dispersion of gross-output price growth"
plot_index_dispersion(price, "Price")
```
## 7. Dispersion and concentration of approximate contributions
Contribution dispersion combines differences in growth with differences in economic size. The second chart tracks the share of **all positive approximate contributions** attributed to the five largest positive contributors. Rankings are calculated for the concentration statistic only; plotted industry order remains unchanged.
```{r}
#| label: price-contribution-dispersion
#| fig-width: 11
#| fig-height: 6
#| fig-cap: "Dispersion of approximate price growth contributions"
plot_index_contribution_dispersion(price, "Price")
```
```{r}
#| label: price-contribution-concentration
#| fig-width: 11
#| fig-height: 6
#| fig-cap: "Top-five share of positive approximate price contributions"
plot_index_concentration(price, "Price")
```
## 8. Latest-quarter dashboard
```{r}
#| label: price-latest-dashboard
#| tbl-cap: "Summary of price index growth, breadth, dispersion, and approximate contributions"
knitr::kable(index_summary_table(price), caption = paste("As of", latest_label))
```
# Cross-series perspective and methodology
## Aggregate nominal, quantity, and price growth
At the aggregate level, nominal growth reflects changes in both quantities and prices. The BEA aggregate nominal total is drawn from `GrossOutput.xlsx`; published quantity and price indexes are read separately from their respective files. These aggregates can be compared, but **this should not be confused with summing industry quantity or price indexes**.
```{r}
#| label: cross-series-growth
#| fig-width: 12
#| fig-height: 7
#| fig-cap: "Aggregate nominal, quantity, and price gross-output growth"
combined_growth <- dplyr::bind_rows(
aggregate |> dplyr::transmute(date, series = "Nominal gross output", qoq = growth_qoq, yoy = growth_yoy),
qty$aggregate |> dplyr::transmute(date, series = "Quantity index", qoq = growth_qoq, yoy = growth_yoy),
price$aggregate |> dplyr::transmute(date, series = "Price index", qoq = growth_qoq, yoy = growth_yoy)
) |>
tidyr::pivot_longer(c(qoq, yoy), names_to = "horizon", values_to = "growth") |>
dplyr::mutate(horizon = factor(horizon, levels = c("qoq", "yoy"), labels = c("QoQ", "YoY")))
ggplot(combined_growth, aes(date, 100 * growth, color = series)) +
geom_hline(yintercept = 0, color = "grey60") +
geom_line(linewidth = .8) +
facet_wrap(~horizon, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "2 years", date_labels = "%Y") +
labs(title = "Nominal growth versus quantity and price growth",
subtitle = "Published aggregate indexes, nominal GO from 71-leaf sum",
x = NULL, y = "Percent", color = NULL)
```
```{r}
#| label: combined-summary-table
#| tbl-cap: "Latest-quarter aggregate growth and breadth across the three datasets"
knitr::kable(tibble::tibble(
Series = c("Nominal gross output", "Quantity index", "Price index"),
`QoQ growth` = c(fmt_pct(tail(aggregate$growth_qoq, 1), 2),
fmt_pct(tail(qty$aggregate$growth_qoq, 1), 2),
fmt_pct(tail(price$aggregate$growth_qoq, 1), 2)),
`YoY growth` = c(fmt_pct(tail(aggregate$growth_yoy, 1), 2),
fmt_pct(tail(qty$aggregate$growth_yoy, 1), 2),
fmt_pct(tail(price$aggregate$growth_yoy, 1), 2)),
`QoQ breadth` = c(fmt_pct(latest_breadth_qoq$breadth),
fmt_pct(tail(dplyr::filter(qty$breadth, horizon == "QoQ")$breadth, 1)),
fmt_pct(tail(dplyr::filter(price$breadth, horizon == "QoQ")$breadth, 1)))
), caption = paste("As of", latest_label))
```
## Accounting and interpretive cautions
1. **Industry selection:** The 71 `Leaf = 1` flags, BEA line IDs, group hierarchy, and plotted order come from `IndustryTaxonomy.xlsx` / `LeafTaxonomy`. The raw observations come from the separate nominal, quantity and price workbooks. This is a line-number join, not a row-position or text-name join.
2. **Nominal additivity:** Only nominal GO levels can be added across the non-overlapping leaves. Summing leaf levels differs slightly from the rounded published BEA total. The nominal contribution identity uses the leaf sum for exact arithmetic.
3. **No index additivity:** Quantity and price levels are chain-type **indexes** with a shared 2017 base, but they cannot be summed. A level of 150 for one industry is not 50% larger output than an index level of 100 for another industry.
4. **Approximate index contributions:** Lagged nominal GO shares times index growth are a transparent *approximation*, not an official BEA Fisher chain-index growth decomposition. The explicit residual measures the discrepancy. These contributions should never be described as exact contributions to chain-type index growth.
5. **Nominal-weighted breadth:** QoQ uses last-quarter nominal GO shares; YoY uses shares four quarters earlier. This offers an economically interpretable size weighting without summing index values.
6. **Nonannualized growth:** The underlying nominal dollars are reported at seasonally adjusted annual rates, but all percentage changes here are **not annualized**.
7. **Gross output versus GDP:** GO includes intermediate production and is not the same as GDP or industry value added.
8. **Interpretation of price breadth:** The price section measures the share of industries whose *gross-output prices* rose. It is not a share of consumer prices or the CPI basket.
9. **Industry reclassifications:** If BEA revises line IDs, the explicit 71-line matching and date checks stop the report rather than silently use an inconsistent subset.
**Source:** U.S. Bureau of Economic Analysis, *Gross Output by Industry*, chain-type quantity indexes for gross output and chain-type price indexes for gross output, BEA extracts revised September 30, 2026. Original files: `GrossOutput.xlsx`, `GrossOutput-Qty.xlsx`, and `GrossOutput-Price.xlsx`; selected using `IndustryTaxonomy.xlsx`.