Causality APIs 14: The Sanction Came After The Shock

Rocket Vector rocket logo on a dark branded background.

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 intensity
  • Inventories: inventory cushion in the market
  • Sanction: policy intensity
  • ShippingRisk: risk moving through trade routes and insurance
  • OilPrice: downstream price level

The graph is:

  • Conflict -> Sanction
  • Conflict -> ShippingRisk
  • Conflict -> OilPrice
  • Inventories -> OilPrice
  • Sanction -> ShippingRisk
  • ShippingRisk -> OilPrice
  • Sanction -> OilPrice

Drawn explicitly, the market story looks like this:

Sanction and oil price DAG

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:

Same market counterfactual without the sanction

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

Discover more from Blogs

Subscribe now to keep reading and get access to the full archive.

Continue reading