How much forest does Oregon have, and how sure are we?¶
Forest Inventory and Analysis estimates with georest.usfs
The Forest Service’s Forest Inventory and Analysis (FIA) program measures trees on tens of thousands of field plots across the country and turns them into statewide numbers: forest area, timber volume, carbon, growth, mortality. Because those numbers come from a sample, every one of them has a standard error. This notebook shows how to get the numbers and how to read their uncertainty:
find what can be estimated and for which inventory,
get Oregon’s forest area, statewide and by county, with error bars,
see why splitting a sample many ways makes some numbers unusable,
combine two states correctly, and
see how much the definition of “forest” matters.
The data come from FIADB-API, the public service behind the Forest Service’s EVALIDator tool.
pip install georest plotly
import pandas as pd
import plotly.express as px
import plotly.io as pio
from georest.usfs import fia
pd.set_option("display.max_colwidth", None) # show text in full, never cut with "..."
pio.renderers.default = "notebook_connected" # interactive here and on georest.io
def label(code_and_name):
"""'`41001 41001 OR Baker' -> 'Baker'; '`0200 Douglas-fir group' -> 'Douglas-fir group'."""
text = str(code_and_name).lstrip("`")
parts = text.split(" ")
if len(parts) > 3 and len(parts[2]) == 2 and parts[2].isupper(): # county labels
return " ".join(parts[3:])
return " ".join(parts[1:]) if parts[0].isdigit() else text
1. What can be estimated, and for which inventory?¶
FIA offers 752 estimate attributes, each with a number (snum). Searching them runs offline
against a catalog that ships with georest.
fia.find_attributes("area of forest land", land_basis="Forest land").head(5)
| snum | description | group | land_basis | units | eval_typ | basis | |
|---|---|---|---|---|---|---|---|
| 0 | 2 | Area of forest land, in acres | Area | Forest land | acres | EXPCURR | COND |
| 1 | 127 | Area change of forest land, in acres, on remeasured conditions where both measurements are forest land | Area change total | Forest land | acres | EXPCHNG | PCOND |
| 2 | 128 | Area change of forest land, in acres, on remeasured conditions where either measurement is forest land | Area change total | Forest land | acres | EXPCHNG | PCOND |
| 3 | 136 | Average annual area change of forest land, in acres, on remeasured conditions where both measurements are forest land | Annual area change | Forest land | acres | EXPCHNG | PCOND |
| 4 | 137 | Average annual area change of forest land, in acres, on remeasured conditions where either measurement is forest land | Annual area change | Forest land | acres | EXPCHNG | PCOND |
Attribute 2 is plain forest area in acres. Next, the evaluation (wc): a state’s
inventory over a span of years. Oregon’s current one pools plots measured from 2015 to 2024.
oregon = fia.find_evaluations("Oregon")
OREGON = int(oregon["wc"].iloc[0])
oregon
| wc | state | statecd | years | growth_acct | most_recent | |
|---|---|---|---|---|---|---|
| 0 | 412024 | Oregon | 41 | 2015;2016;2017;2018;2019;2020;2021;2022;2023;2024 | Y | Y |
2. Oregon’s forest area, statewide and by county¶
estimate returns one row per cell with its estimate, its standard error as a percentage
(se_pct), and the number of plots behind it. Totals arrive as the row labelled Total.
by_county = fia.estimate(OREGON, 2, rselected="County code and name")
total = by_county[by_county["row"] == "Total"].iloc[0]
half_width = 1.96 * total["estimate"] * total["se_pct"] / 100
print(f"Oregon has {total['estimate'] / 1e6:.2f} million acres of forest land, "
f"with a standard error of {total['se_pct']:.2f}% from {total['plots']:,} plots.")
print(f"95% confidence interval: {(total['estimate'] - half_width) / 1e6:.2f} to "
f"{(total['estimate'] + half_width) / 1e6:.2f} million acres.")
Oregon has 29.78 million acres of forest land, with a standard error of 0.43% from 10,592 plots.
95% confidence interval: 29.53 to 30.03 million acres.
Statewide, the estimate is tight. County by county it is not: each county has far fewer plots. The bars below show each county’s estimate with its 95% interval. Hover for the plot count and standard error; a county in orange is flagged as too uncertain to report.
counties = by_county[by_county["row"] != "Total"].copy()
counties["county"] = counties["row"].map(label)
counties["ci95"] = 1.96 * counties["estimate"] * counties["se_pct"] / 100
counties["status"] = counties["unreliable"].map({True: "too uncertain", False: "reportable"})
counties = counties.sort_values("estimate")
fig = px.bar(counties, x="estimate", y="county", orientation="h", error_x="ci95",
color="status", color_discrete_map={"reportable": "#2f6f4f", "too uncertain": "#d9480f"},
custom_data=["plots", "se_pct", "unreliable_reason"],
labels={"estimate": "Forest land (acres)", "county": ""},
title="Oregon forest land by county, with 95% intervals")
fig.update_traces(hovertemplate=("<b>%{y}</b><br>%{x:,.0f} acres<br>"
"%{customdata[0]} plots, SE %{customdata[1]:.1f}%"
"<br>%{customdata[2]}<extra></extra>"))
fig.update_layout(height=820, legend_title_text="")
fig.show()
3. Split a sample many ways and some numbers stop meaning anything¶
Grouping by forest type divides the same plots among 16 groups. The common types are still
precise; the rare ones rest on a handful of plots. estimate flags any cell with fewer than
30 plots or a standard error above 30%, and says why.
by_type = fia.estimate(OREGON, 2, rselected="Forest type group")
types = by_type[by_type["row"] != "Total"].copy()
types["forest type"] = types["row"].map(label)
types["ci95"] = 1.96 * types["estimate"] * types["se_pct"] / 100
types["status"] = types["unreliable"].map({True: "too uncertain", False: "reportable"})
types = types.sort_values("estimate")
fig = px.bar(types, x="estimate", y="forest type", orientation="h", error_x="ci95",
color="status", color_discrete_map={"reportable": "#2f6f4f", "too uncertain": "#d9480f"},
custom_data=["plots", "se_pct", "unreliable_reason"],
labels={"estimate": "Forest land (acres)", "forest type": ""},
title="Oregon forest land by forest type group")
fig.update_traces(hovertemplate=("<b>%{y}</b><br>%{x:,.0f} acres<br>"
"%{customdata[0]} plots, SE %{customdata[1]:.1f}%"
"<br>%{customdata[2]}<extra></extra>"))
fig.update_layout(height=560, legend_title_text="")
fig.show()
types.loc[types["unreliable"], ["forest type", "estimate", "se_pct", "plots", "unreliable_reason"]]
| forest type | estimate | se_pct | plots | unreliable_reason | |
|---|---|---|---|---|---|
| 8 | Oak / hickory group | 1571.262810 | 94.134912 | 1 | single plot - no variance estimate possible |
| 2 | Western white pine group | 9614.857898 | 42.626107 | 7 | only 7 plots (< 30); normal approximation for a confidence interval does not hold |
| 10 | Aspen / birch group | 59825.644771 | 26.629777 | 24 | only 24 plots (< 30); normal approximation for a confidence interval does not hold |
| 9 | Elm / ash / cottonwood group | 77650.500373 | 22.950610 | 26 | only 26 plots (< 30); normal approximation for a confidence interval does not hold |
Those flagged rows are still returned, not silently dropped: how much of a table is too thin
to use is worth knowing. When you want them gone, fia.reliable(df) removes them.
print(f"{len(fia.reliable(types))} of {len(types)} forest type groups are reportable.")
12 of 16 forest type groups are reportable.
4. Two states together¶
To estimate across states, pass a list of evaluations. The totals add up either way, but the standard error of a combined estimate cannot be worked out by adding the states’ errors. Here is Oregon plus Washington done three ways.
WASHINGTON = int(fia.find_evaluations("Washington")["wc"].iloc[0])
def state_total(df):
return df[df["row"] == "Total"].iloc[0]
combined = state_total(fia.estimate([OREGON, WASHINGTON], 2))
separate = [state_total(fia.estimate(wc, 2)) for wc in (OREGON, WASHINGTON)]
se_acres = [r["estimate"] * r["se_pct"] / 100 for r in separate]
added = sum(r["estimate"] for r in separate)
pd.DataFrame([
{"method": "one request, both evaluations (correct)",
"acres": combined["estimate"], "se_pct": combined["se_pct"]},
{"method": "add the errors (wrong: overstates)",
"acres": added, "se_pct": 100 * sum(se_acres) / added},
{"method": "combine errors in quadrature",
"acres": added, "se_pct": 100 * sum(s ** 2 for s in se_acres) ** 0.5 / added},
]).style.format({"acres": "{:,.0f}", "se_pct": "{:.3f}%"}).hide(axis="index")
| method | acres | se_pct |
|---|---|---|
| one request, both evaluations (correct) | 51,804,091 | 0.331% |
| add the errors (wrong: overstates) | 51,804,091 | 0.467% |
| combine errors in quadrature | 51,804,091 | 0.331% |
For two separate states, whose samples are independent, combining the errors in quadrature
matches FIA’s own answer. That shortcut breaks inside a state, where the rows of an estimate
share the same sample design: there, only a single request (with str_filter to choose the
area) gives the right error.
5. What counts as forest?¶
FIA and the Resources Planning Act (RPA) assessment define forest land differently, and in a
dry state with a lot of woodland the difference is large. forest_definition picks one; it is
always sent explicitly so two people pulling “the same” number get the same answer.
NEVADA = int(fia.find_evaluations("Nevada")["wc"].iloc[0])
rows = []
for definition in ("FIADEF", "RPADEF"):
t = state_total(fia.estimate(NEVADA, 2, forest_definition=definition))
rows.append({"definition": definition, "acres": t["estimate"],
"ci95": 1.96 * t["estimate"] * t["se_pct"] / 100, "plots": t["plots"]})
defs = pd.DataFrame(rows)
fig = px.bar(defs, x="definition", y="acres", error_y="ci95", color="definition",
color_discrete_sequence=["#2f6f4f", "#8c6d1f"], custom_data=["plots"],
labels={"acres": "Forest land (acres)", "definition": ""},
title="Nevada forest land under the FIA and RPA definitions")
fig.update_traces(hovertemplate="%{x}: %{y:,.0f} acres<br>%{customdata[0]:,} plots<extra></extra>")
fig.update_layout(showlegend=False)
fig.show()
Where to go next¶
Other attributes.
fia.find_attributes("carbon"),"net growth","mortality". Growth, removals and mortality need an evaluation with growth accounting:fia.find_evaluations("Oregon", growth_only=True).Other breakdowns.
fia.find_groupings("owner")lists grouping labels; pass one asrselected=and another ascselected=for a cross-table.A sub-area.
str_filternarrows the plots, for examplestr_filter="plot.unitcd in (0, 1, 2)"for western Oregon’s survey units.Warnings. Anything FIADB-API flags about an estimate is in
df.attrs["warnings"].
Full reference: georest.usfs.fia.