> For the complete documentation index, see [llms.txt](https://docs.rootcause.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.rootcause.ai/api-and-integrations/sdk-getting-started/sdk-working-with-twins.md).

# Working with Digital Twins

This guide covers the full twin lifecycle: inspecting a discovered graph, encoding domain knowledge, training, asking a trained twin every question the platform can answer, sampling raw draws, and moving trained twins between environments. Outputs shown are real transcripts.

## The causal graph

Discovery returns a `Graph`. Its edges are a DataFrame, and the adjacency matrix comes labelled:

```python
>>> graph = rc.discover(df)
>>> graph.edges
             cause   effect  strength  fixed
0            leads  revenue  0.957978  False
1  marketing_spend    leads  0.864212  False
2      seasonality    leads  0.373061  False

>>> graph.adjacency()
                 marketing_spend  seasonality     leads   revenue
marketing_spend              0.0          0.0  0.864212  0.000000
seasonality                  0.0          0.0  0.373061  0.000000
leads                        0.0          0.0  0.000000  0.957978
revenue                      0.0          0.0  0.000000  0.000000
```

`adjacency` also takes `values="sign"` or `values="bool"`, and `to_numpy()` and `to_networkx()` convert onward (networkx needs `pip install "rootcause-sdk[graph]"`).

> The distribution is `rootcause-sdk`, not `rootcause` — only the *import* name is `rootcause`, and `pip install "rootcause[graph]"` reaches an unrelated PyPI project.

## Domain knowledge

Encode what you know with two verbs. `pin` fixes an edge as present, `forbid` fixes it as absent, and both write into the version's fixed subgraph that discovery and training honour:

```python
>>> graph.pin("marketing_spend", "leads")
>>> graph.edges
             cause   effect  strength  fixed
0            leads  revenue  0.957978  False
1  marketing_spend    leads  0.864212   True
2      seasonality    leads  0.373061  False
```

## Training

```python
>>> twin = graph.train()
>>> twin
Twin('sdk-twin-ab1ea6651290', kind=static, version=jg8U3O9M6ufF1HJW3XSOO, state=trained)
```

`train` blocks until the model is fitted. Calling it on an already trained version returns the twin unchanged with a note: the platform retrains through new versions, not by re-fitting in place.

Retraining is two verbs. `new_version` derives a fresh, untrained version — configuration and causal graph inherited from the base, every training output reset — and `retrain` is `new_version` plus `train` in one call:

```python
>>> fresh = twin.new_version(bump="minor")   # 1.0.0 -> 1.1.0, untrained
>>> trained = twin.retrain()                 # derive + train, blocks until fitted
```

Version numbers never collide: the bumped component skips past any label already taken. To rebuild a *direct-mode* model from scratch (after an engine fix, or a corrupt artifact), `rc.discover(df, force=True)` remains the recovery path.

In platform mode you rarely train at all; a twin someone trained in the UI is ready to query:

```python
>>> twin = ws.twin("C8 Temporal")
```

## Keeping a trained model current

New rows landing in the twin's backing source do not require a retrain. `update()` folds them into the trained model incrementally — seconds, not minutes — and reports what happened rather than failing:

```python
>>> result = twin.update()
>>> result
UpdateResult(status='committed', rows=60)
```

The three statuses are the contract: `committed` (new rows folded in), `up_to_date` (nothing new since the last update), and `retrain_required` (the model can't take these rows incrementally — `result.reasons` says why; call `twin.retrain()`). Static and temporal twins assimilate out of the box. Among panel twins, only multi-environment-temporal ones can assimilate, and only when they opt into the v2 panel engine in the twin builder; a multi-environment-static twin has no v2 option and always needs a retrain. `twin.update_eligibility` answers the same question read-only, so an orchestrator can decide without starting a job. The full monthly-refresh pattern, including the Airflow shape, is in [Temporal and Panel Twins](/api-and-integrations/sdk-getting-started/sdk-temporal-and-panel-twins.md#monthly-refresh-assimilate-instead-of-retrain).

## Asking a trained twin a question

Each verb blocks until the run completes. Which verb a twin accepts depends on what kind of twin it is, and the SDK refuses the wrong one before submitting anything:

| Question                         | Verb         | Twin kinds                           |
| -------------------------------- | ------------ | ------------------------------------ |
| What will this specific case do? | `predict`    | static, multi-environment static     |
| What happens over time?          | `forecast`   | temporal, multi-environment temporal |
| What if we change X?             | `intervene`  | every kind                           |
| Why does this happen?            | `explain`    | every kind                           |
| What should we change?           | `optimise`   | every kind                           |
| How do I reach a goal?           | `score`      | static                               |
| Why is this variable broken?     | `root_cause` | every kind                           |
| Is anything broken at all?       | `anomalies`  | every kind                           |

`intervene`, `forecast` and `score` have sections of their own further down; the rest are covered here.

### Prediction

`predict` answers for the rows you hand it: one prediction per input record, with an uncertainty interval around each. Leave the target columns out of the input: those are what the model answers with.

```python
>>> at_risk = pd.DataFrame([
...     {"tenure": 3,  "MonthlyCharges": 85.0, "Contract": "Month-to-month"},
...     {"tenure": 40, "MonthlyCharges": 20.0, "Contract": "Two year"},
... ])
>>> result = twin.predict(at_risk, targets=["Churn"])
>>> result.to_frame()[["row", "prediction", "probabilities"]]
   row prediction                                 probabilities
0    0        Yes  [0.5774936183230309, 0.4225063816769691]
1    1         No  [0.1032418871283461, 0.8967581128716539]
```

The `row` column is the position of the input record each prediction answers for, so the frame joins straight back onto the one you asked about. A second target adds a `variable` column instead of dropping a series, and `confidence=` sets the interval width.

Targets are inferred from the version's variable roles when you leave `targets` off. Prediction reads one row at a time, so it is a static-twin verb: a temporal twin projects forward with `forecast` instead, and says so rather than guessing.

### Explanation

`explain` asks the model why, and the mode follows from what you name, so you rarely pass `mode` yourself:

```python
>>> twin.explain(effect="Churn")                      # what drives churn
>>> twin.explain(cause="Contract")                    # what contract length goes on to affect
>>> twin.explain(cause="Contract", effect="Churn")    # the paths from one to the other
```

The result carries a ranked driver list with effect sizes, confidence intervals, dose-response curves for numeric causes, and the split between direct and indirect pathways. Panel twins take `environments=` to narrow which environments are explained.

### Optimization

`optimise` searches for the actions that best move your objectives. Objectives are measured by SQL over the sampled frame, which is registered as `df`, `data`, and `dataset`; `decision_vars` is the set of levers the optimizer is allowed to touch:

```python
>>> churn = rc.objective(
...     "Churn share",
...     "SELECT AVG(CASE WHEN Churn = 'Yes' THEN 1.0 ELSE 0.0 END) AS value FROM df",
...     "minimise",
... )
>>> result = twin.optimise([churn], decision_vars=["Contract", "MonthlyCharges"])
```

`rc.objective` takes either spelling of maximise/minimise, plus `unit=` and `weight=` for trading several objectives off against each other. Add `variable_constraints=` to bound how far a lever may move, `metric_constraints=` for guardrails every plan must respect, and `max_changes=` to cap how many variables one plan may touch.

A temporal twin optimizes over a horizon and needs `horizon=`; a static one optimizes a single period and refuses it. Panel twins take `environments=`.

> A categorical outcome has to be counted, not averaged. `SELECT AVG("Churn")` over a text column is not a number, and the run fails inside the engine rather than at submission. Count the category you care about with `CASE WHEN`, as above.

### Diagnosis

Two verbs, and which one you want depends on whether you already know what is wrong. `root_cause` traces one named variable upstream to what actually broke it:

```python
>>> observed = pd.DataFrame([{"tenure": 2, "MonthlyCharges": 105.0, "Churn": "Yes"}])
>>> twin.root_cause("Churn", observed)
```

`anomalies` scans every variable instead, and diagnoses whatever it flags:

```python
>>> twin.anomalies(observed)
```

Both take `target_fpr=` to set detection sensitivity as a false-positive rate (lower flags less), and both need the observations you want diagnosed: there is no scanning the training data by default. On a temporal twin `root_cause` takes a `timestep=` to diagnose and `anomalies` takes a `start_step`/`end_step` window.

Panel twins can either share one set of rows across every environment, by passing a flat list, or give each environment its own by passing a mapping:

```python
>>> twin.anomalies({"uk": uk_rows, "france": fr_rows})
```

### When there is no verb for it

`ask` runs the platform's own scenario generator over a plain-English question and executes whatever it produces, which reaches the families that have no dedicated verb yet:

```python
>>> result = twin.ask("what happens to bookings if we cut trade shows entirely?")
>>> result.scenario["type"]
'intervention'
```

`result.scenario` is what the translator built, so it doubles as the way to discover a scenario shape you then send yourself.

## Batch scoring

Point the trained model at rows and ask what it would take to change each one's outcome. For every row, the counterfactual engine finds the smallest set of changes that reaches the target — a risk register with an action column:

```python
>>> at_risk = pd.DataFrame([
...     {"customer": "cust-104", "tenure": 3,  "monthly_charge": 92, "support_calls": 5},
...     {"customer": "cust-221", "tenure": 41, "monthly_charge": 45, "support_calls": 0},
... ])
>>> result = twin.score(at_risk, targets=[{"variable": "churn", "value": "no"}])
>>> result.digest["verdictCounts"]
{'flips': 1, 'withinTolerance': 0, 'closestOnly': 0, 'alreadyMet': 1}
>>> result.to_frame()[["label", "flip.variable", "flip.toValue", "changeCount"]]
      label    flip.variable  flip.toValue  changeCount
0  cust-104   monthly_charge          61.0            2
1  cust-221             None           NaN            0
```

Static trained twins only; a non-variable column (like `customer` above) becomes the row label. `max_changes=` caps how much each counterfactual may touch, and `constraints=` locks variables the business cannot move.

## Sweeps

Instead of pinning a variable to one value, sweep it across a grid with `rc.range` and read the full dose-response curve back:

```python
>>> result = twin.intervene({"marketing": rc.range(20, 80, steps=8)}, outcomes=["revenue"])
>>> curve = result.sweep()          # one metric on the run, so no metric= needed
>>> curve.attrs["sweptVariable"]
'marketing'
>>> curve[["causeValue", "effectMean"]].tail(3)
   causeValue  effectMean
5   62.857143   93.858985
6   71.428571  128.703131
7   80.000000  129.031563
```

Each point also carries `effectStd` and a `confidenceInterval`.

One `rc.range` per scenario; every other intervention in it is pinned, so the curve reads as the effect of that one dial in a fixed context.

## Raw sampling

Every simulation the platform offers is built on conditional sampling from the fitted model. The SDK exposes that primitive directly, so you can compute your own estimands instead of waiting for a packaged analysis:

```python
>>> draws = twin.sample(n=2000, seed=42)
>>> draws.to_frame().describe().round(1)
       marketing_spend  seasonality   leads  revenue
count           2000.0       2000.0  2000.0   2000.0
mean              48.2         -0.1   142.8    313.1
std               10.9          1.0    36.9     84.3
min               15.3         -3.6    19.4     60.5
25%               41.2         -0.7   118.2    255.2
50%               48.3         -0.1   141.7    313.0
75%               55.4          0.6   166.8    370.7
max               82.6          2.6   253.0    562.5
```

Apply interventions before sampling with `do=`, and compare against baseline:

```python
>>> boosted = twin.sample(n=2000, do={"marketing_spend": rc.pct(+20)}, seed=42)
>>> pd.DataFrame({
...     "baseline": draws.to_frame().mean(),
...     "do(marketing +20%)": boosted.to_frame().mean(),
... }).round(1)
                 baseline  do(marketing +20%)
marketing_spend      48.2                57.9
seasonality          -0.1                -0.1
leads               142.8               164.9
revenue             313.1               358.0
```

The 20 percent push propagates through the chain the graph discovered: marketing lifts leads, leads lift revenue, and seasonality is untouched because nothing points at it.

**Note:** seeds are reproducible across every twin family. Panel twins sample each environment independently and derive stable per-environment child seeds from your seed, so backtest comparisons are deterministic. Pass `environments=["uk", "france"]` to narrow a panel twin; the returned frame gains an `environment` column.

### Intervention values

A bare value means "set to exactly this". The constructors cover the rest:

| Constructor                             | Meaning                                                |
| --------------------------------------- | ------------------------------------------------------ |
| `rc.set(120)`                           | set the variable to 120                                |
| `rc.pct(+15)`                           | relative change of +15 percent                         |
| `rc.add(-5)`                            | relative change of -5 units                            |
| `rc.prob("yes", 0.8)`                   | set a category's probability to 0.8                    |
| `rc.adjust_prob("yes", +10)`            | shift a category's probability by 10 percentage points |
| `rc.members(include=["Alice"], size=4)` | set-valued column membership                           |

Conditions scope any intervention to a subpopulation: `where={"region": "EMEA"}` for equality, or `where={"income": ("<", 5000)}` with any of `== != > < >= <=`.

## Interventions with metrics

`intervene` runs the full simulation machinery server side and blocks for the result. Interventions measure their effect through metrics; the simplest form names outcome columns and gets mean-of-column metrics:

```python
>>> result = twin.intervene({"marketing_spend": rc.pct(+25)}, outcomes=["revenue", "leads"])
>>> result
SimulationResult(intervention, run=VSL59DI43QnsTWDcVszak, status=completed)
>>> result.summary       # the narrative digest
>>> result.to_frame()    # tabular results
```

Full control uses SQL metrics over the sampled frame, which is registered under the table names `df`, `data`, and `dataset`:

```python
>>> result = twin.intervene(
...     {"tech_support": rc.prob("yes", 1.0)},
...     metrics=[rc.metric(
...         "churn_rate",
...         "SELECT AVG(CASE WHEN churn = 'Yes' THEN 1.0 ELSE 0.0 END) AS value FROM df",
...         unit="ratio",
...         higher_is_better=False,
...     )],
... )
```

Calling `intervene` with neither `outcomes` nor `metrics` raises immediately with guidance, before any job is submitted:

```python
>>> twin.intervene({"marketing_spend": rc.pct(+5)})
RootCauseError: Interventions need at least one metric. Pass outcomes=['revenue'] for
mean-of-column metrics, metrics=[rc.metric(...)] for custom SQL, or use
twin.sample(do=...) for raw draws.
```

## Forecasts

Temporal and panel-temporal twins forecast. Target variables are inferred from the version's variable roles when unambiguous, or passed explicitly. [Temporal and Panel Twins](/api-and-integrations/sdk-getting-started/sdk-temporal-and-panel-twins.md) covers forecasting in depth, including attribution and backtest anchoring:

```python
>>> fc = twin.forecast(horizon=24, targets=["revenue"], environments=["uk"])
>>> fc.to_frame()        # environment, series, timestamps, confidence bands
```

## Portable twins

Twin exports carry the trained model parameters, so a `.rctwin` file round-trips to a runnable model:

```python
>>> twin.save("c8.rctwin")
PosixPath('c8.rctwin')
>>> twin2 = rc.load_twin("c8.rctwin")    # later, anywhere, same authentication
```

Compute always stays on the platform; the file makes the model portable between environments, not the algorithms.

## Cleaning up

`twin.delete()` removes a twin permanently — fitted models, every version, the run history, and the record itself; running workflows are cancelled first. Sources and datasets answer the same verb. There is no undo, which makes the iterate-and-discard loop explicit:

```python
>>> for twin in ws.twins:
...     if twin.name.startswith("experiment-"):
...         twin.delete()
```

Deletion needs the matching scope on your key (`digital-twins:delete`, `sources:delete`, `datasets:delete`).

## Next steps

* [Temporal and Panel Twins](/api-and-integrations/sdk-getting-started/sdk-temporal-and-panel-twins.md): time series, environments, scheduled interventions, forecast attribution
* [Ontology Queries](/api-and-integrations/sdk-getting-started/sdk-ontology-queries.md)
* [Interactive Apps in Notebooks](/api-and-integrations/sdk-getting-started/sdk-notebook-apps.md)
* [Python API Reference](/api-and-integrations/sdk-getting-started/sdk-api-reference.md)
