Sanctions hit, oil jumps, and the obvious public story writes itself: the policy caused the spike.
That story is tempting because the price move is visible and the headline arrives all at once. But sanctions rarely land in a calm market. By the time the policy hits, conflict intensity, shipping fear, and inventory nerves are often already moving the price.
That is where people start asking a counterfactual that is too large to answer honestly:
- what would the entire region have looked like if the conflict never escalated?
That is a civilization-sized question. It is too big for one neat model and too vague to be useful.
The better move is to narrow the intervention.
Instead of asking for the counterfactual history of the whole conflict, ask something like this:
- once the geopolitical shock was already in motion, how much additional oil-price pressure came from the sanction itself?
That is a much more defensible causal question.
The sanction can matter a lot while still not owning the whole price move, because the underlying conflict was already pushing the market before the sanction arrived.
That is why this makes a strong final example for the series.
Start with a policy-sized question
The ordinary market argument says:
- sanctions were announced
- oil jumped
- sanctions caused the jump
The more careful version says:
- conflict intensity was already increasing shipping risk and market fear
- sanctions may have added more pressure on top of that
That is the right scale for a causal model. Not “rewrite history,” but “what did this policy move add inside an already volatile environment?”
Draw the graph
A compact version is:
Conflict: underlying conflict intensityInventories: inventory cushion in the marketSanction: policy intensityShippingRisk: risk moving through trade routes and insuranceOilPrice: downstream price level
The graph is:
Conflict -> SanctionConflict -> ShippingRiskConflict -> OilPriceInventories -> OilPriceSanction -> ShippingRiskShippingRisk -> OilPriceSanction -> OilPrice
Drawn explicitly, the market story looks like this:

With py-bbn, the first question is straightforward:
import networkx as nx
from pybbn.graphical import get_graph_tuple, get_minimal_confounders, get_paths
g = nx.DiGraph()
g.add_edges_from(
[
("Conflict", "Sanction"),
("Conflict", "ShippingRisk"),
("Conflict", "OilPrice"),
("Inventories", "OilPrice"),
("Sanction", "ShippingRisk"),
("ShippingRisk", "OilPrice"),
("Sanction", "OilPrice"),
]
)
gt = get_graph_tuple(g)
get_minimal_confounders(gt, "Sanction", "OilPrice")
# ['Conflict']
get_paths(gt, "Sanction", "OilPrice")
That is the structural point people often skip. The sanction is not floating in space. It is partly triggered by the same conflict intensity that was already moving oil.
Use py-scm for the market quantities
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 = ["Conflict", "Inventories", "Sanction", "ShippingRisk", "OilPrice"]
weighted_edges = [
("Conflict", "Sanction", 0.9),
("Conflict", "ShippingRisk", 0.8),
("Conflict", "OilPrice", 1.5),
("Inventories", "OilPrice", -1.0),
("Sanction", "ShippingRisk", 1.2),
("ShippingRisk", "OilPrice", 1.3),
("Sanction", "OilPrice", 0.4),
]
model = build_linear_model(nodes, weighted_edges)
The raw sanction slice overstates the policy move
If you just condition on a strong sanction state:
mean, _ = model.pquery({"Sanction": 1.0})
float(mean["OilPrice"])
# 3.2230
That gives a price move of about 3.22 units in the sanction state.
But that still bundles in the conflict shock that helped generate the sanction in the first place.
The intervention isolates the sanction effect
model.iquery("OilPrice", {"Sanction": 1.0})
# mean 1.96
model.iquery("OilPrice", {"Sanction": 0.0})
# mean 0.00
model.equery("OilPrice", {"Sanction": 1.0}, {"Sanction": 0.0})
# mean 1.96
In this toy model, the sanction still matters. The point is not to erase the policy effect. The point is to keep it from absorbing the whole geopolitical shock.
So the intervention effect is about +1.96, not the +3.22 implied by the raw sanction-state comparison.
Counterfactuals let you revisit one market day
Suppose oil closes at 5.4 in a high-conflict, low-inventory setting after the sanction hits.
You can ask:
model.cquery(
"OilPrice",
factual={
"Conflict": 1.2,
"Inventories": -0.3,
"Sanction": 1.0,
"ShippingRisk": 2.0,
"OilPrice": 5.4,
},
counterfactual=[{"Sanction": 0.0}],
)
In this toy counterfactual, the same market state falls to about 3.44 without the sanction.
This is the right scale for the geopolitical counterfactual:

It keeps the conflict shock and inventory backdrop in place and asks only what the sanction added on top.
That is a much better geopolitical claim than “the sanction caused the whole spike.” It says the sanction plausibly added roughly two units of pressure on top of a larger conflict environment that was already moving the market.
What this buys you
This is the right way to use causal reasoning on large public events:
- avoid the impossible civilization-scale counterfactual
- ask a narrower intervention question
- use the graph to separate the policy from the background shock
- compute the interventional and counterfactual quantities that match the real argument
That is more honest, more useful, and much easier to defend.
This is also the larger point of the whole series. A causal API should not just hand back one number. It should help the reader ask a better question, inspect the graph that question implies, and then compute the quantity that actually matches the decision or claim at hand.


Leave a Reply