Skip to content

Commit 98855c5

Browse files
committed
Add pert distribution to gen_kw
1 parent 47757c5 commit 98855c5

6 files changed

Lines changed: 250 additions & 4 deletions

File tree

docs/ert/reference/configuration/data_types.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,42 @@ To define a triangular distribution with a minimum of 1, mode (peak) of 3, and m
394394

395395
.. image:: fig/triangular.png
396396

397+
PERT: PERT Distribution
398+
^^^^^^^^^^^^^^^^^^^^^^^
399+
400+
The ``PERT`` keyword defines a beta-PERT distribution from a minimum, mode,
401+
and maximum value. An optional scale controls how strongly the distribution
402+
is concentrated around the mode.
403+
404+
Syntax
405+
~~~~~~
406+
::
407+
408+
VAR PERT MIN MODE MAX [SCALE]
409+
410+
Parameters
411+
~~~~~~~~~~
412+
- **MIN**: The minimum value of the distribution.
413+
- **MODE**: The most likely value. It must be strictly between ``MIN`` and
414+
``MAX``.
415+
- **MAX**: The maximum value of the distribution.
416+
- **SCALE**: An optional value greater than zero. The default is ``4``.
417+
Larger values concentrate the distribution more strongly around ``MODE``.
418+
419+
Examples
420+
~~~~~~~~
421+
To use the default scale:
422+
423+
::
424+
425+
VAR_PERT PERT 1 3 5
426+
427+
To use an explicit scale:
428+
429+
::
430+
431+
VAR_PERT_SCALED PERT 1 3 5 2
432+
397433
3D field parameters: ``FIELD``
398434
------------------------------
399435

src/ert/config/distribution.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import numpy as np
88
from pydantic import BaseModel, Field, field_validator, model_validator
9-
from scipy.special import ndtr
9+
from scipy.special import betaincinv, ndtr
1010

1111
from .parsing import ConfigValidationError, ConfigWarning, ErrorInfo
1212

@@ -28,6 +28,10 @@ def get_param_names(cls) -> list[str]:
2828
if field.is_required() or (field.default is not None and name != "name")
2929
]
3030

31+
@classmethod
32+
def get_min_param_count(cls) -> int:
33+
return len(cls.get_param_names())
34+
3135

3236
class UnifSettings(TransSettingsValidation):
3337
name: Literal["uniform"] = "uniform"
@@ -380,6 +384,56 @@ def transform_numpy(self, x: np.ndarray) -> np.ndarray:
380384
return np.clip(result, self.min, self.max)
381385

382386

387+
class PertSettings(TransSettingsValidation):
388+
model_config = {"allow_inf_nan": False}
389+
390+
name: Literal["pert"] = "pert"
391+
min: float = 0.0
392+
mode: float = 0.5
393+
max: float = 1.0
394+
scale: float = 4.0
395+
396+
@classmethod
397+
def get_min_param_count(cls) -> int:
398+
return 3
399+
400+
@model_validator(mode="after")
401+
def valid_pert_params(self) -> Self:
402+
errors = []
403+
if not self.min < self.max:
404+
errors.append(
405+
ErrorInfo(
406+
message=f"Minimum {self.min} must be strictly less than"
407+
f" the maximum {self.max} for pert distribution"
408+
)
409+
)
410+
if not (self.min < self.mode < self.max):
411+
errors.append(
412+
ErrorInfo(
413+
message=f"The mode {self.mode} must be strictly between"
414+
f" the minimum {self.min} and maximum {self.max}"
415+
" for pert distribution"
416+
)
417+
)
418+
if self.scale <= 0:
419+
errors.append(
420+
ErrorInfo(
421+
message=f"Scale {self.scale} must be strictly greater than 0"
422+
" for pert distribution"
423+
)
424+
)
425+
if errors:
426+
raise ConfigValidationError.from_collected(errors)
427+
return self
428+
429+
def transform_numpy(self, x: np.ndarray) -> np.ndarray:
430+
span = self.max - self.min
431+
alpha = 1 + self.scale * (self.mode - self.min) / span
432+
beta = 1 + self.scale * (self.max - self.mode) / span
433+
result = self.min + span * betaincinv(alpha, beta, ndtr(x))
434+
return np.clip(result, self.min, self.max)
435+
436+
383437
DistributionSettings = Annotated[
384438
UnifSettings
385439
| LogNormalSettings
@@ -391,7 +445,8 @@ def transform_numpy(self, x: np.ndarray) -> np.ndarray:
391445
| TruncNormalSettings
392446
| ErrfSettings
393447
| DerrfSettings
394-
| TriangularSettings,
448+
| TriangularSettings
449+
| PertSettings,
395450
Field(discriminator="name"),
396451
]
397452

@@ -407,6 +462,7 @@ def transform_numpy(self, x: np.ndarray) -> np.ndarray:
407462
"TRIANGULAR": TriangularSettings,
408463
"ERRF": ErrfSettings,
409464
"DERRF": DerrfSettings,
465+
"PERT": PertSettings,
410466
}
411467

412468

src/ert/config/gen_kw_config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,12 @@ def _parse_distribution(
371371
)
372372
dist_cls = DISTRIBUTION_CLASSES[dist_name]
373373

374-
if len(values) != len(dist_cls.get_param_names()):
374+
param_count = len(values)
375+
if not (
376+
dist_cls.get_min_param_count()
377+
<= param_count
378+
<= len(dist_cls.get_param_names())
379+
):
375380
raise ConfigValidationError.with_context(
376381
f"Incorrect number of values: {values}, provided for variable "
377382
f"{param_name} with distribution {dist_name}.",

tests/ert/ui_tests/cli/test_update.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"LOGNORMAL": ["MEAN", "STD"],
3232
"TRUNCATED_NORMAL": ["MEAN", "STD", "MIN", "MAX"],
3333
"TRIANGULAR": ["MIN", "MODE", "MAX"],
34+
"PERT": ["MIN", "MODE", "MAX", "SCALE"],
3435
"UNIFORM": ["MIN", "MAX"],
3536
"DUNIF": ["STEPS", "MIN", "MAX"],
3637
"ERRF": ["MIN", "MAX", "SKEWNESS", "WIDTH"],

tests/ert/unit_tests/config/test_gen_kw_config.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,25 @@ def test_short_definition_raises_config_error(tmp_path):
142142
{"name": "KEY10", "distribution": {"name": "const", "value": 10}},
143143
{"key": "KEY10", "function": "CONST", "parameters": {"VALUE": 10}},
144144
),
145+
(
146+
{
147+
"name": "KEY11",
148+
"distribution": {
149+
"name": "pert",
150+
"min": 0,
151+
"mode": 1,
152+
"max": 2,
153+
"scale": 4,
154+
},
155+
},
156+
{
157+
"key": "KEY11",
158+
"function": "PERT",
159+
"parameters": {"MIN": 0, "MODE": 1, "MAX": 2, "SCALE": 4},
160+
},
161+
),
145162
],
146-
ids=[f"KEY{i}" for i in range(1, 11)],
163+
ids=[f"KEY{i}" for i in range(1, 12)],
147164
)
148165
def test_gen_kw_config_get_priors(spec, expected):
149166
cfg = GenKwConfig(**spec)
@@ -187,6 +204,7 @@ def test_gen_kw_config_get_priors(spec, expected):
187204
("ERRF 1 2 0.1 0.1", False, r"KW_NAME:MY_KEYWORD " + number_regex),
188205
("DERRF 10 1 2 0.1 0.1", False, r"KW_NAME:MY_KEYWORD " + number_regex),
189206
("TRIANGULAR 0 0.5 1", False, r"KW_NAME:MY_KEYWORD " + number_regex),
207+
("PERT 0 0.5 1", False, r"KW_NAME:MY_KEYWORD " + number_regex),
190208
],
191209
)
192210
async def test_gen_kw_is_log_or_not(
@@ -403,6 +421,74 @@ def test_gen_kw_params_parsing(tmpdir, params, error):
403421
)
404422

405423

424+
@pytest.mark.parametrize(
425+
("values", "expected_scale"),
426+
[
427+
(["0", "0.5", "1"], 4.0),
428+
(["0", "0.5", "1", "2"], 2.0),
429+
],
430+
)
431+
def test_that_pert_uses_default_or_explicit_scale(values, expected_scale):
432+
distribution = GenKwConfig._parse_distribution("MYNAME", "PERT", values)
433+
434+
assert distribution.model_dump() == {
435+
"name": "pert",
436+
"min": 0.0,
437+
"mode": 0.5,
438+
"max": 1.0,
439+
"scale": expected_scale,
440+
}
441+
442+
443+
@pytest.mark.parametrize(
444+
"values",
445+
[
446+
["0", "0.5"],
447+
["0", "0.5", "1", "4", "5"],
448+
],
449+
)
450+
def test_that_pert_rejects_parameter_counts_other_than_three_or_four(values):
451+
with pytest.raises(ConfigValidationError, match="Incorrect number of values"):
452+
GenKwConfig._parse_distribution("MYNAME", "PERT", values)
453+
454+
455+
def test_that_pert_requires_minimum_strictly_less_than_maximum():
456+
with pytest.raises(
457+
ConfigValidationError,
458+
match=r"Minimum .* must be strictly less than the maximum",
459+
):
460+
GenKwConfig._parse_distribution("MYNAME", "PERT", ["1", "1", "1"])
461+
462+
463+
@pytest.mark.parametrize("mode", ["-1", "0", "1", "2"])
464+
def test_that_pert_requires_mode_strictly_between_bounds(mode):
465+
with pytest.raises(ConfigValidationError, match="must be strictly between"):
466+
GenKwConfig._parse_distribution("MYNAME", "PERT", ["0", mode, "1"])
467+
468+
469+
@pytest.mark.parametrize(
470+
"parameter_index",
471+
range(4),
472+
ids=["minimum", "mode", "maximum", "scale"],
473+
)
474+
@pytest.mark.parametrize("value", ["nan", "inf", "-inf"])
475+
def test_that_pert_requires_finite_parameters(parameter_index, value):
476+
values = ["0", "0.5", "1", "4"]
477+
values[parameter_index] = value
478+
479+
with pytest.raises(ConfigValidationError, match="finite"):
480+
GenKwConfig._parse_distribution("MYNAME", "PERT", values)
481+
482+
483+
@pytest.mark.parametrize("scale", ["0", "-1"])
484+
def test_that_pert_requires_positive_scale(scale):
485+
with pytest.raises(
486+
ConfigValidationError,
487+
match=r"strictly greater than 0",
488+
):
489+
GenKwConfig._parse_distribution("MYNAME", "PERT", ["0", "0.5", "1", scale])
490+
491+
406492
@pytest.mark.parametrize(
407493
("params", "xinput", "expected"),
408494
[

tests/ert/unit_tests/config/test_transfer_functions.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55
from hypothesis import assume, given
66
from hypothesis import strategies as st
7+
from scipy.stats import beta as beta_distribution
78
from scipy.stats import norm
89

910
from ert.config.distribution import TransSettingsValidation, get_distribution
@@ -245,3 +246,64 @@ def test_that_triangular_is_monotonic(args):
245246
assert y1 >= y2
246247
else:
247248
assert y1 <= y2
249+
250+
251+
def valid_pert_params():
252+
return nice_floats(min_value=-1e6, max_value=1e6).flatmap(
253+
lambda mode: st.tuples(
254+
st.floats(
255+
min_value=mode - 2,
256+
max_value=mode - 1,
257+
allow_nan=False,
258+
allow_infinity=False,
259+
),
260+
st.just(mode),
261+
st.floats(
262+
min_value=mode + 1,
263+
max_value=mode + 2,
264+
allow_nan=False,
265+
allow_infinity=False,
266+
).filter(lambda maximum: maximum > mode),
267+
nice_floats(min_value=0.1, max_value=20),
268+
)
269+
)
270+
271+
272+
@given(nice_floats(), valid_pert_params())
273+
def test_that_pert_stays_within_bounds(x, args):
274+
minimum, _, maximum, _ = args
275+
dist = get_distribution("PERT", args)
276+
277+
assert minimum <= transform_scalar(dist, x) <= maximum
278+
279+
280+
def test_that_pert_transform_does_not_exceed_max_from_roundoff():
281+
dist = get_distribution("PERT", [-1.003, 0, 1, 1])
282+
283+
assert transform_scalar(dist, 9) == 1
284+
285+
286+
@given(
287+
st.tuples(nice_floats(), nice_floats()).map(sorted),
288+
valid_pert_params(),
289+
)
290+
def test_that_pert_is_non_strictly_monotonic(x_values, args):
291+
x1, x2 = x_values
292+
dist = get_distribution("PERT", args)
293+
294+
assert transform_scalar(dist, x1) <= transform_scalar(dist, x2)
295+
296+
297+
@given(
298+
nice_floats(min_value=-8, max_value=8),
299+
valid_pert_params(),
300+
)
301+
def test_that_pert_matches_scaled_beta_quantiles(x, args):
302+
minimum, mode, maximum, scale = args
303+
span = maximum - minimum
304+
alpha = 1 + scale * (mode - minimum) / span
305+
beta = 1 + scale * (maximum - mode) / span
306+
expected = beta_distribution.ppf(norm.cdf(x), alpha, beta, loc=minimum, scale=span)
307+
dist = get_distribution("PERT", args)
308+
309+
assert np.isclose(transform_scalar(dist, x), expected)

0 commit comments

Comments
 (0)