mixle.analysis.real_options module¶
Real options & decision-under-uncertainty (work-plan Sec.7-J, J3).
A risk-neutral NPV distribution (J2’s NPVDistribution) answers “what
is the project worth if we commit now?” It does not answer the question a real decision-maker actually
faces: “should we commit now, or is it worth paying for the option to wait, expand, or walk away once
more is known?” Two tools close that gap:
real_option_value()– prices the option embedded in the decision (defer / expand / abandon) with a binomial lattice on the NPV process itself, American-exercised at every step. NPV – unlike a commodity price – is a signed quantity that must be able to cross zero for “wait and see” to mean anything, so the lattice is additive (an arithmetic random walk aroundnpv_dist.mean, scaled byvolatilityas a fraction of the NPV’s own magnitude) rather than the usual multiplicative GBM lattice: a strictly-positive geometric process started above zero can never reach themax(V, 0)kink that gives the option its value. Volatility is what makes the option valuable: by Jensen’s inequality, an American option on a driftless process is worth strictly more than the naive point NPV whenever there is dispersion wide enough to reach the kink, and it collapses to the naivemax(NPV, 0)as volatility falls to zero (no dispersion, nothing to wait for). Eachkindcarries its own exercise economics:expandnets the capacity bonus against anexpansion_costinvestment, so expanding is a genuine trade-off rather than a bonus every positive-NPV project gets for free, andabandonrecovers an optionalsalvage_valueinstead of assuming a total write-off.
voi_dollars()– the dollar value of a piece of information (e.g. a delineation drillhole): the expected value of the best decision with that information, minus the expected value of the best decision without it. A declaredobservation_modelis required by default so this is a real expected value of sample information (EVSI). A Gaussian-dispersion HEURISTIC remains available only through the explicitdrill_info["method"] = "variance_rescaling_heuristic"opt-in: a hypothetical drillhole’s effect on the posterior is summarized as a fractional variance reduction (either supplied directly viadrill_info["variance_reduction"], or – when available – via C8’smixle_pde.voi.expected_variance_reductionhook), and the pre-posterior simulation invents plausible future-posterior centers/spreads by RESCALING today’s posterior draws rather than sampling a hypothetical observation from a real likelihood and conditioning on it – exact only for a Gaussian, unimodal, unconstrained posterior, and flagged with a warning otherwise. For a genuine sample-then-condition preposterior, declare a linear-GaussianGaussianObservationModel(observation_model=): a hypothetical observation is then simulated from the latent’s actual prior-predictive marginal and the posterior is conditioned on it analytically via closed-form conjugate-Gaussian updating – the textbook EVSI construction, for that declared-likelihood case. A rational decision-maker never loses from more information in expectation, but a Monte Carlo estimate of that gain is noisy around zero when there is little or no information to gain;voi_estimate()reports the (unfloored) estimate together with its own standard error rather than clipping the noise away, which – naively floored at zero – was systematically biased upward rather than merely conservative (VoiEstimate).
Repo-boundary note: J2’s mixle.analysis.valuation module (NPVDistribution / monte_carlo_npv)
has since landed and is imported normally below; C8’s mixle_pde VOI hooks have not, so
voi_dollars still best-effort imports that hook and falls back to a self-contained
posterior-refinement simulation over the frozen IC-1 Posterior
when it is unavailable. real_option_value’s npv_dist parameter stays duck-typed against
anything exposing a .mean – the hard import below is for the type annotation and documentation
value only (previously a TYPE_CHECKING-only forward reference, which left the name unresolvable to
typing.get_type_hints() – a real, if rarely exercised, runtime introspection break); no
isinstance check was added, so any duck-typed object still works exactly as before.
- class OptionValue(value, exercise_boundary, premium_over_npv)[source]
Bases:
NamedTupleThe priced option, alongside the exercise policy that generated it.
valueis the total value of holding the option (project value + optionality).exercise_boundaryis the per-time-step critical underlying value at which immediate exercise first becomes optimal (nanat any step where it is never optimal to exercise).premium_over_npvisvalueminus the naivenpv_dist.mean– the dollar amount the flexibility to defer/expand/abandon is worth over just committing to the point-estimate NPV today.- value: float
Alias for field number 0
- exercise_boundary: ndarray
Alias for field number 1
- premium_over_npv: float
Alias for field number 2
- real_option_value(npv_dist, *, volatility, horizon, kind='defer', rate, n_steps=None, expand_fraction=0.3, expansion_cost=0.0, salvage_value=0.0)[source]
Price the defer/expand/abandon option on a project via an additive binomial lattice on NPV.
The NPV process is modeled as a driftless arithmetic random walk started at
npv_dist.mean: each ofn_steps(default:max(horizon, 1)) steps up tohorizonmoves the value by+-hwithh = volatility * scale * sqrt(dt),scale = abs(npv_dist.mean)(or1.0if the mean is exactly zero, sovolatilityis never degenerate) – i.e.volatilityis a fractional per-sqrt-period dispersion relative to the project’s own scale, matching how the parameter is usually quoted (e.g.0.3~ “30% dispersion”), while still letting the walk go negative the way a signed NPV must be able to. The risk-neutral measure is taken driftless (an NPV is already the discounted expectation of a self-financing project, so there is no further risk-neutral drift to add);rateonly discounts the continuation value, which is what makes immediate exercise optimal once volatility vanishes. At every node the holder may exercise (payoff via_intrinsic(), depending onkind) or continue holding the option; the American value is the larger of the two, by backward induction from the horizon.- Parameters:
npv_dist (NPVDistribution) – anything exposing
.mean(a float) – typically J2’sNPVDistribution. Only the mean is used;real_option_valueprices the option on top of the point estimate, not a re-derivation of the distribution itself. Must be finite.volatility (float) – fractional (per-sqrt-period) dispersion of NPV around its mean. Must be finite and
>= 0;0means no dispersion and the option collapses to immediate exercise ofkind’s own payoff onnpv_dist.mean(_intrinsic()) –max(npv_dist.mean, 0)for"defer",max(npv_dist.mean, salvage_value)for"abandon",npv_dist.mean + max(expand_fraction * max(npv_dist.mean, 0) - expansion_cost, 0)for"expand".horizon (int) – number of periods over which the option may be exercised.
0means “decide now”. Must be a non-Boolean integral scalar>= 0.kind (str) – one of
"defer","expand","abandon".rate (float) – per-period discount rate applied to the continuation value. Must be finite.
n_steps (int | None) – lattice steps (defaults to
max(horizon, 1), i.e. one step per period). Must be a non-Boolean positive integral scalar when given explicitly.expand_fraction (float) – for
kind="expand", the fractional capacity bonus applied to a positive underlying value when the expansion is exercised. Must be finite and nonnegative.expansion_cost (float) – for
kind="expand", the investment cost of actually exercising the expansion, netted againstexpand_fraction’s capacity bonus – without it, a positive-NPV project always “expands” for free and there is no real exercise decision being priced (see_intrinsic()). Ignored for other kinds. Must be finite and nonnegative; default0.0(no cost, matching this function’s behavior beforeexpansion_costexisted).salvage_value (float) – for
kind="abandon", the value recovered by abandoning the project instead of riding out its NPV, replacing the previous hardcoded assumption of a total write-off. Ignored for other kinds. Must be finite; default0.0(total write-off, matching this function’s behavior beforesalvage_valueexisted).
- Returns:
An
OptionValue.- Return type:
OptionValue
- class GaussianObservationModel(obs_matrix, obs_cov)[source]
Bases:
NamedTupleA declared linear-Gaussian observation model:
y = obs_matrix @ theta + noise,noise ~ N(0, obs_cov), for a latentthetadistributed as the current posterior’s(mean, cov).MXR-080-0109: the opt-in heuristic invents future-posterior centers/spreads by rescaling draws from the CURRENT posterior; it never samples an observation from a likelihood/forward model and conditions on it, so it is not a real Bayesian preposterior for non-Gaussian, multimodal, or constrained beliefs. This is the simplest well-defined experiment likelihood for which the preposterior – the distribution of what TOMORROW’s posterior could look like, before the observation is actually taken – has a closed form: conjugate-Gaussian updating. Passing this to
voi_dollars()/voi_estimate()(asobservation_model=) is what makes them compute a genuine expected value of sample information (EVSI): simulate a hypotheticalyfrom theta’s actual prior-predictive marginal, analytically condition the CURRENT posterior on thatyto get a real future posterior, decide against it, and average over many simulatedy– rather than the opt-in heuristic’s rescaling of today’s draws (seevoi_estimate()’s docstring for why that heuristic is not a real preposterior in general).obs_matrix(H, shape(k, d)) andobs_cov(R, shape(k, k), symmetric positive definite) together say: a real experiment would return ak-dimensional noisy linear readout of thed-dimensional latent quantity the posterior is over (d=posterior.mean’s length). This path requiresposterior.mean/posterior.covto fully characterize the belief – i.e. a genuinely Gaussian posterior – since that is what makes the conjugate update exact; a denseposterior.covis required (a matrix-freeLinearOperatorcovariance is not supported here).obs_matrix = 0(orobs_covlarge) declares a literally uninformative experiment – VOI comes back exactly zero for that case, not just approximately (seevoi_estimate()’s docstring).- obs_matrix: ndarray
Alias for field number 0
- obs_cov: ndarray
Alias for field number 1
- class VoiEstimate(value, standard_error, ci_low, ci_high, method)[source]
Bases:
NamedTupleA Monte Carlo value-of-information estimate, reported honestly alongside its own sampling noise (MXR-080-0108: independent no-info/with-info draws made a should-be-zero difference noisy, and flooring that noise at zero upward-biased it; see
voi_estimate()’s docstring for the fix).valueis the point estimate,E[value | info] - E[value | no info]– NOT floored at zero (seevoi_estimate()’s docstring for why flooring a noisy estimate at zero systematically biases it upward, rather than merely rounding it).standard_erroris the paired-difference Monte Carlo standard error ofvalueacross then_outerhypothetical-information replicates (paired because each replicate’s with-info and no-info evaluations share their underlying random draw – seevoi_estimate()).ci_low/ci_highare an approximate two-sided 95% confidence interval (value +- 1.96 * standard_error; a normal approximation, not exact for smalln_outer). Whenci_low <= 0 <= ci_high,valueis statistically indistinguishable from “no additional value” – a more honest read than a bare point estimate that happens to be (noisily) positive or negative.methodnames which computation produced the estimate:"variance_rescaling_heuristic"(an explicit opt-in Gaussian-dispersion approximation, exact only when the posterior is close to Gaussian, unimodal, and unconstrained) or"gaussian_conjugate_evsi"(a genuine sample-then-condition preposterior computation under a declared linear-Gaussianobservation_model).- value: float
Alias for field number 0
- standard_error: float
Alias for field number 1
- ci_low: float
Alias for field number 2
- ci_high: float
Alias for field number 3
- method: str
Alias for field number 4
- voi_estimate(posterior, decision_fn, drill_info, *, rng, n_outer=64, n_inner=256, observation_model=None)[source]
Value of information, honestly: the point estimate together with its own Monte Carlo uncertainty.
decision_fnmaps a set of posterior draws (an(n, d)array, physical units) to the dollar value of the single best decision made using that belief state – e.g. a risk-neutral go/no-go choice ismax(samples.mean(), 0), not an average of a per-draw payoff: the latter implicitly assumes the realization is already known, which is exactly the perfect-information case this function is pricing the gap to, not the belief itself.Two methods are available:
observation_model=Nonetogether with explicitdrill_info["method"] == "variance_rescaling_heuristic": a pre-posterior Monte Carlo built on the law of total variance, NOT real Bayesian conditioning on a simulated observation. Today’s posterior variance splits into “where the post-drill posterior will be centered” (unknown until the drillhole is actually put in) and “how much spread remains once it lands there.” If the drillhole is expected to remove a fractionrof variance (_variance_reduction()), then forn_outerhypothetical drill outcomes a center is drawn with standard deviation scaled bysqrt(r)around today’s posterior mean (how much the belief could plausibly shift), and a shared base draw (see “Common random numbers” below) is re-centered there and rescaled bysqrt(1 - r)for the remaining spread. This INVENTS future-belief “centers” by rescaling today’s draws rather than drawing them from an actual forward/likelihood model, so it is accurate only when the posterior is (close to) Gaussian, unimodal, and unconstrained – outside that regime it is flagged with aUserWarning(see_warn_if_not_gaussian_like()), not silently trusted.observation_model=aGaussianObservationModel(method="gaussian_conjugate_evsi"): a genuine sample-then-condition preposterior. A hypothetical observationyis simulated from the latent’s real prior-predictive marginal (y = H @ theta + noisefortheta ~ N(posterior.mean, posterior.cov)), the CURRENT posterior is analytically conditioned on thatyvia closed-form conjugate-Gaussian updating to get a genuine future posterior, and the decision is re-made against it – averaged over many simulatedyforE[value | info]. This is the textbook EVSI construction for the declared-likelihood case, but it still requires the PRIOR (posterior.mean/posterior.cov) to be genuinely Gaussian for the conjugate update to be exact – also screened by the same warning.
Common random numbers (MXR-080-0108): each of the
n_outerreplicates draws ONE base/innovation sample and uses it for BOTH the no-info decision and the with-info (refined) decision for that replicate, differing only by a deterministic transform (the heuristic’s re-centering/rescaling, or the conjugate update’s affine map) – not independent draws for the two sides. This matters when there is truly no information to gain (r = 0for the heuristic;obs_matrix = 0orobs_covarbitrarily large for the declared-likelihood path): with independent draws, the no-info and with-info values would be two separately noisy estimates of the same quantity, so their difference would be noise centered at zero; flooring that noise at zero (the previous behavior) kept only its positive half, upward-biasing the reported VOI (a standard-normal fake posterior with a go/no-go decision spuriously returned positive VOI at zero information for most tested seeds). With a shared draw, the with-info value is bit-for-bit identical to the no-info value whenever there is truly no information (checked directly for the heuristic via an analytic short-circuit below, not just left to fall out of the Monte Carlo – and provably exact by construction for the declared-likelihood path too), and the two stay correlated – hence lower-variance in their difference – away from that boundary as well.- Parameters:
posterior (Posterior) – the current (pre-drill) belief, satisfying IC-1’s
Posteriorprotocol.decision_fn (Callable[[ndarray], float]) – belief state (
(n, d)draws) -> expected dollar value of the best decision.drill_info (dict[str, Any]) – describes the hypothetical drillhole. Recognized keys:
method(must explicitly be"variance_rescaling_heuristic"when noobservation_modelis supplied),variance_reduction(direct fractional variance reduction, default0.5), orcandidate_geometry+forward_op(+ optionalregion/cell_volumes) to route through the C8 VOI hook when available;n_outer_samples/n_inner_samplesoverride the Monte Carlo sample counts. Ignored (aside from the sample-count overrides) whenobservation_modelis given.rng (Generator) – seeded random generator for reproducibility.
n_outer (int) – number of hypothetical-information replicates (overridden by
drill_info).n_inner (int) – posterior draws per replicate (overridden by
drill_info).observation_model (GaussianObservationModel | None) – when given, a declared
GaussianObservationModelroutes the computation through the genuine"gaussian_conjugate_evsi"path instead of the opt-in heuristic; see above. Requires a dense (non-LinearOperator)posterior.cov.
- Returns:
the (unfloored) point estimate plus its Monte Carlo uncertainty.
- Return type:
A
VoiEstimate
- voi_dollars(posterior, decision_fn, drill_info, *, rng, n_outer=64, n_inner=256, observation_model=None)[source]
The value-of-information point estimate, in dollars:
E[value | info] - E[value | no info].A thin convenience wrapper around
voi_estimate()for callers that only want the point estimate; see its docstring for the full method (the opt-in heuristic vs. the genuineobservation_modelEVSI path, common random numbers, the zero-information analytic equality, and whatmethodthe estimate used) and for how to also get the Monte Carlo standard error / CI.- Parameters:
posterior (Posterior) – forwarded to
voi_estimate()unchanged.decision_fn (Callable[[ndarray], float]) – forwarded to
voi_estimate()unchanged.drill_info (dict[str, Any]) – forwarded to
voi_estimate()unchanged.rng (Generator) – forwarded to
voi_estimate()unchanged.n_outer (int) – forwarded to
voi_estimate()unchanged.n_inner (int) – forwarded to
voi_estimate()unchanged.observation_model (GaussianObservationModel | None) – forwarded to
voi_estimate()unchanged.
- Returns:
The value-of-information point estimate, in dollars. NOT floored at zero – see
voi_estimate()’s docstring for why a floor is dishonest, not merely conservative; this can be a small negative number when the true VOI is at or near zero, which is the honest reflection of Monte Carlo noise, not a defect. Usevoi_estimate()if you need to tell that case apart from a real, larger difference.- Return type:
- class VoiStoppingDecision(voi_dollars, sample_cost, net_value, keep_sampling, standard_error)[source]
Bases:
NamedTupleA real decision-theoretic answer to “should we sample again?”: compare the value of one more sample against its cost, rather than an arbitrary uncertainty threshold picked by hand.
standard_erroris the underlyingVoiEstimate’s Monte Carlo standard error, carried through so a caller can tell a close-to-the-costnet_value(statistical noise) apart from a real, larger gap – the same honestyvoi_estimate()reports at the VOI level (MXR-080-0108/0110).- Parameters:
- voi_dollars: float
Alias for field number 0
- sample_cost: float
Alias for field number 1
- net_value: float
Alias for field number 2
- keep_sampling: bool
Alias for field number 3
- standard_error: float
Alias for field number 4
- voi_stopping_decision(posterior, decision_fn, drill_info, *, sample_cost, rng, n_outer=64, n_inner=256, observation_model=None)[source]
Should the next sample (drillhole, monitoring well, survey station, …) actually be taken?
A real, principled alternative to picking an uncertainty threshold by hand and telling an LLM loop-controller to stop when it’s cleared (the pattern used, and flagged as a real gap, in
experiments/adaptive-groundwater-monitoringandexperiments/adaptive-gravity-survey-design): sample again iffvoi_dollars()– the actual expected dollar value the next sample would add to the decision – exceeds what that sample costs. As uncertainty tightens,voi_dollarsshrinks toward zero (there’s less left to learn that would change the decision), so this converges to a stopping rule on its own without any separately-chosen threshold; the only free parameter issample_cost, which is an actual real-world number (drilling/survey cost) rather than an arbitrary uncertainty width.- Parameters:
posterior (Posterior) – forwarded to
voi_estimate()unchanged – see its docstring for what each means.decision_fn (Callable[[ndarray], float]) – forwarded to
voi_estimate()unchanged – see its docstring for what each means.drill_info (dict[str, Any]) – forwarded to
voi_estimate()unchanged – see its docstring for what each means.rng (Generator) – forwarded to
voi_estimate()unchanged – see its docstring for what each means.n_outer (int) – forwarded to
voi_estimate()unchanged – see its docstring for what each means.n_inner (int) – forwarded to
voi_estimate()unchanged – see its docstring for what each means.observation_model (GaussianObservationModel | None) – forwarded to
voi_estimate()unchanged – see its docstring for what each means.sample_cost (float) – the real dollar cost of taking the next sample. Must be finite (MXR-080-0110: an unvalidated NaN cost previously compared as
Falseagainst everything, silently forcingkeep_sampling=Falseregardless of the actual VOI).
- Returns:
the computed VOI (with its standard error), the cost it was compared against, their difference, and whether sampling should continue (
voi_dollars > sample_cost).- Return type:
A
VoiStoppingDecision