When a tariff lands and shelf prices jump, the argument usually skips straight to blame. Retailers point at the policy. Skeptics point at shipping costs, volatile categories, or both.
Tariff arguments get messy fast because they mix three ideas that are not the same:
- which products are exposed
- what happens to import costs after the policy
- what consumers actually see on the shelf
People usually jump straight from headline to blame:
- tariff announced
- price rose
- tariff did it
That can be directionally right and still analytically sloppy.
A tariff can be a real cause of higher shelf prices while the raw tariff-versus-no-tariff comparison overstates the effect because exposed products were never an ordinary comparison group.
This is exactly the kind of public economic question where a graph helps before regression ever enters the room.
Start with the ordinary retail argument
Suppose a retailer says the tariff is pushing up prices. A skeptic replies that exposed categories were already volatile, shipping costs were already moving, and those were not random items to begin with.
That is already a graph:
- some categories are more import-dependent than others
- import dependence affects both tariff exposure and baseline cost structure
- shipping and import cost transmit part of the policy effect into the shelf price
Ask the graph question first
In a compact toy version:
Dependence: category import dependenceTariff: the policy exposure levelShipping: shipping-cost pressureImportCost: landed cost after the policyShelfPrice: consumer-facing price index
Drawn explicitly, that retail story looks like this:

With py-bbn, the first thing you want to know is the backdoor story.
import networkx as nx from pybbn.graphical import get_graph_tuple, get_minimal_confounders, get_paths g = nx.DiGraph() g.add_edges_from( [ ("Dependence", "Shipping"), ("Dependence", "Tariff"), ("Dependence", "ShelfPrice"), ("Shipping", "ImportCost"), ("Tariff", "ImportCost"), ("ImportCost", "ShelfPrice"), ("Shipping", "ShelfPrice"), ("Tariff", "ShelfPrice"), ] ) gt = get_graph_tuple(g) get_minimal_confounders(gt, "Tariff", "ShelfPrice")# ['Dependence']get_paths(gt, "Tariff", "ShelfPrice")
That is a useful answer because it separates two different mistakes:
- failing to account for category dependence
- controlling away the very mediator path through
ImportCostthat you are trying to measure as part of the total effect
Use py-scm for the cost and price indices
import numpy as np
from pyscm.reasoning import create_reasoning_model
def build_linear_model(nodes, weighted_edges):
idx = {node: i for i, node in enumerate(nodes)}
B = np.zeros((len(nodes), len(nodes)))
D = np.eye(len(nodes))
for parent, child, weight in weighted_edges:
B[idx[child], idx[parent]] = weight
A = np.eye(len(nodes)) - B
cov = np.linalg.inv(A) @ D @ np.linalg.inv(A).T
return create_reasoning_model(
{"nodes": nodes, "edges": [(p, c) for p, c, _ in weighted_edges]},
{"v": nodes, "m": [0.0] * len(nodes), "S": cov.tolist()},
)
nodes = ["Dependence", "Shipping", "Tariff", "ImportCost", "ShelfPrice"]
weighted_edges = [
("Dependence", "Shipping", 0.8),
("Dependence", "Tariff", 0.9),
("Dependence", "ShelfPrice", 0.6),
("Shipping", "ImportCost", 0.7),
("Tariff", "ImportCost", 1.3),
("ImportCost", "ShelfPrice", 0.9),
("Shipping", "ShelfPrice", 1.1),
("Tariff", "ShelfPrice", 0.2),
]
model = build_linear_model(nodes, weighted_edges)
The exposed categories look more expensive than the tariff effect alone
If you just compare tariff-exposed and non-exposed categories:
mean, _ = model.pquery({"Tariff": 1.0}) float(mean["ShelfPrice"])# 2.3565
That is the kind of number that ends up in an argument thread. Tariff-exposed goods look about 2.36 units more expensive on the shelf-price index.
But that still bundles together tariff exposure and the fact that high-dependence categories were structurally different to begin with.
The intervention isolates the policy move
model.iquery("ShelfPrice", {"Tariff": 1.0})# mean 1.37model.iquery("ShelfPrice", {"Tariff": 0.0})# mean 0.00model.equery("ShelfPrice", {"Tariff": 1.0}, {"Tariff": 0.0})# mean 1.37
In this toy model, the tariff still raises the shelf-price index. The point is not to make the tariff disappear. The point is to keep the estimate from borrowing extra lift from the category mix.
So the interventional effect is about +1.37, not the +2.36 implied by the raw exposed-versus-unexposed comparison.
Counterfactuals make the story product-level
Now imagine one specific item with a factual shelf-price index of 3.3 under the tariff regime.
model.cquery(
"ShelfPrice",
factual={
"Dependence": 1.1,
"Shipping": 0.8,
"Tariff": 1.0,
"ImportCost": 2.0,
"ShelfPrice": 3.3,
},
counterfactual=[{"Tariff": 0.0}],
)
In this toy counterfactual, the same item falls to about 1.93 without the tariff.
That product-level counterfactual is easier to read as a concrete before-and-after for the same item:

It keeps the category and shipping backdrop fixed and changes only the tariff state.
That is a better retail question than “did the tariff matter?” It asks: for this class of exposed item, how much of the shelf move can plausibly be traced to the policy once you separate it from the category itself?
What this buys you
This is why graph queries matter alongside numeric inference:
- the graph tells you what kind of comparison is broken
- the intervention tells you the policy effect
- the counterfactual turns the same model into a product-level story people can understand
That is a much better way to reason about trade-policy claims than arguing from the headline and a screenshot of prices.
Next in the series: falling vaccination can matter before an outbreak, but confirmed-case slices can still send you in the wrong direction.

Leave a Reply