Skip to content

Physics Constraints

Soft physics constraints through virtual observations for known limits and monotonic trends.

matgpr.physics_constraints

VirtualObservationSet dataclass

Physics-derived pseudo-observations for soft GPR constraints.

Virtual observations are useful when physics gives trusted anchor behavior that is not directly present in a small experimental dataset. Examples include zero-response limits, saturation values, or local monotonic trends.

These observations are soft constraints: they influence the fitted GP, but they do not mathematically guarantee global monotonicity or exact boundary behavior.

Source code in matgpr/physics_constraints.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@dataclass(frozen=True)
class VirtualObservationSet:
    """Physics-derived pseudo-observations for soft GPR constraints.

    Virtual observations are useful when physics gives trusted anchor behavior
    that is not directly present in a small experimental dataset. Examples
    include zero-response limits, saturation values, or local monotonic trends.

    These observations are soft constraints: they influence the fitted GP, but
    they do not mathematically guarantee global monotonicity or exact boundary
    behavior.
    """

    X: np.ndarray
    y: np.ndarray
    noise_std: float | Sequence[float] | np.ndarray | None = None
    labels: Sequence[str] | np.ndarray | None = None

    def __post_init__(self) -> None:
        x_values = to_2d_finite(self.X, "X")
        y_values = to_1d_finite(self.y, "y")
        validate_same_row_count(x_values, y_values, "X", "y")

        noise = None
        if self.noise_std is not None:
            noise = noise_std_array(self.noise_std, y_values.shape[0])

        labels = label_array(self.labels, y_values.shape[0], default="virtual")

        object.__setattr__(self, "X", x_values)
        object.__setattr__(self, "y", y_values)
        object.__setattr__(self, "noise_std", noise)
        object.__setattr__(self, "labels", labels)

    @property
    def n_observations(self) -> int:
        """Number of virtual observations."""
        return int(self.X.shape[0])

    @property
    def n_features(self) -> int:
        """Number of feature columns."""
        return int(self.X.shape[1])

    @property
    def alpha(self) -> np.ndarray | None:
        """Observation variances suitable for scikit-learn GPR ``alpha``."""
        if self.noise_std is None:
            return None
        return self.noise_std**2

n_observations property

Number of virtual observations.

n_features property

Number of feature columns.

alpha property

Observation variances suitable for scikit-learn GPR alpha.

KnownLimitConstraint dataclass

Create virtual observations at a known feature limit.

This class encodes boundary behavior such as fraction = 0 at zero time, a known saturation value at high loading, or a physically required response at a limiting temperature/composition. The selected feature is replaced by limit_value for each reference row while all other features are kept unchanged.

Source code in matgpr/physics_constraints.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@dataclass(frozen=True)
class KnownLimitConstraint:
    """Create virtual observations at a known feature limit.

    This class encodes boundary behavior such as ``fraction = 0`` at zero time,
    a known saturation value at high loading, or a physically required response
    at a limiting temperature/composition. The selected feature is replaced by
    ``limit_value`` for each reference row while all other features are kept
    unchanged.
    """

    feature: int | str
    limit_value: float
    target_value: float | Callable[[np.ndarray], float | Sequence[float] | np.ndarray]
    noise_std: float | Sequence[float] | np.ndarray = 0.05
    label: str = "known_limit"

    def generate(self, X_reference) -> VirtualObservationSet:
        """Generate virtual observations from reference feature rows."""
        x_values, columns = _as_numeric_matrix(X_reference, "X_reference")
        feature_index = _resolve_feature_index(self.feature, columns, x_values.shape[1])
        limit_value = finite_scalar(self.limit_value, "limit_value")

        x_virtual = x_values.copy()
        x_virtual[:, feature_index] = limit_value
        y_virtual = _evaluate_target_value(self.target_value, x_virtual)
        noise = noise_std_array(self.noise_std, x_virtual.shape[0])
        labels = np.full(x_virtual.shape[0], self.label, dtype=object)

        return VirtualObservationSet(
            X=x_virtual,
            y=y_virtual,
            noise_std=noise,
            labels=labels,
        )

generate(X_reference)

Generate virtual observations from reference feature rows.

Source code in matgpr/physics_constraints.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def generate(self, X_reference) -> VirtualObservationSet:
    """Generate virtual observations from reference feature rows."""
    x_values, columns = _as_numeric_matrix(X_reference, "X_reference")
    feature_index = _resolve_feature_index(self.feature, columns, x_values.shape[1])
    limit_value = finite_scalar(self.limit_value, "limit_value")

    x_virtual = x_values.copy()
    x_virtual[:, feature_index] = limit_value
    y_virtual = _evaluate_target_value(self.target_value, x_virtual)
    noise = noise_std_array(self.noise_std, x_virtual.shape[0])
    labels = np.full(x_virtual.shape[0], self.label, dtype=object)

    return VirtualObservationSet(
        X=x_virtual,
        y=y_virtual,
        noise_std=noise,
        labels=labels,
    )

MonotonicTrendConstraint dataclass

Create local virtual observations that encode a monotonic trend.

The constraint shifts one feature by step and assigns a target that is consistent with an increasing or decreasing response. For an increasing trend and positive step, the virtual target is y_reference + minimum_slope * step. For a decreasing trend, the sign is reversed. Negative steps are allowed and move the anchor in the opposite feature direction.

This is a soft data-augmentation strategy for low-data GPR workflows. It is not a replacement for derivative-constrained Gaussian-process inference.

Source code in matgpr/physics_constraints.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
@dataclass(frozen=True)
class MonotonicTrendConstraint:
    """Create local virtual observations that encode a monotonic trend.

    The constraint shifts one feature by ``step`` and assigns a target that is
    consistent with an increasing or decreasing response. For an increasing
    trend and positive ``step``, the virtual target is
    ``y_reference + minimum_slope * step``. For a decreasing trend, the sign is
    reversed. Negative steps are allowed and move the anchor in the opposite
    feature direction.

    This is a soft data-augmentation strategy for low-data GPR workflows. It is
    not a replacement for derivative-constrained Gaussian-process inference.
    """

    feature: int | str
    direction: str
    step: float | None = None
    minimum_slope: float = 0.0
    feature_min: float | None = None
    feature_max: float | None = None
    noise_std: float | Sequence[float] | np.ndarray = 0.1
    label: str | None = None

    def generate(self, X_reference, y_reference) -> VirtualObservationSet:
        """Generate monotonic virtual observations from reference rows."""
        x_values, columns = _as_numeric_matrix(X_reference, "X_reference")
        y_values = to_1d_finite(y_reference, "y_reference")
        validate_same_row_count(x_values, y_values, "X_reference", "y_reference")

        feature_index = _resolve_feature_index(self.feature, columns, x_values.shape[1])
        direction_sign = response_sign(self.direction)
        step = _resolve_step(self.step, x_values[:, feature_index])
        minimum_slope = nonnegative_scalar(self.minimum_slope, "minimum_slope")

        x_virtual = x_values.copy()
        x_virtual[:, feature_index] = x_virtual[:, feature_index] + step
        y_virtual = y_values + direction_sign * minimum_slope * step
        noise = noise_std_array(self.noise_std, x_virtual.shape[0])

        keep_mask = feature_bounds_mask(
            x_virtual[:, feature_index],
            feature_min=self.feature_min,
            feature_max=self.feature_max,
        )
        if not np.any(keep_mask):
            raise ValueError(
                "No monotonic virtual observations remain after applying feature bounds"
            )

        x_virtual = x_virtual[keep_mask]
        y_virtual = y_virtual[keep_mask]
        noise = noise[keep_mask]
        label = self.label or f"monotonic_{normalized_direction(self.direction)}"
        labels = np.full(x_virtual.shape[0], label, dtype=object)

        return VirtualObservationSet(
            X=x_virtual,
            y=y_virtual,
            noise_std=noise,
            labels=labels,
        )

generate(X_reference, y_reference)

Generate monotonic virtual observations from reference rows.

Source code in matgpr/physics_constraints.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
def generate(self, X_reference, y_reference) -> VirtualObservationSet:
    """Generate monotonic virtual observations from reference rows."""
    x_values, columns = _as_numeric_matrix(X_reference, "X_reference")
    y_values = to_1d_finite(y_reference, "y_reference")
    validate_same_row_count(x_values, y_values, "X_reference", "y_reference")

    feature_index = _resolve_feature_index(self.feature, columns, x_values.shape[1])
    direction_sign = response_sign(self.direction)
    step = _resolve_step(self.step, x_values[:, feature_index])
    minimum_slope = nonnegative_scalar(self.minimum_slope, "minimum_slope")

    x_virtual = x_values.copy()
    x_virtual[:, feature_index] = x_virtual[:, feature_index] + step
    y_virtual = y_values + direction_sign * minimum_slope * step
    noise = noise_std_array(self.noise_std, x_virtual.shape[0])

    keep_mask = feature_bounds_mask(
        x_virtual[:, feature_index],
        feature_min=self.feature_min,
        feature_max=self.feature_max,
    )
    if not np.any(keep_mask):
        raise ValueError(
            "No monotonic virtual observations remain after applying feature bounds"
        )

    x_virtual = x_virtual[keep_mask]
    y_virtual = y_virtual[keep_mask]
    noise = noise[keep_mask]
    label = self.label or f"monotonic_{normalized_direction(self.direction)}"
    labels = np.full(x_virtual.shape[0], label, dtype=object)

    return VirtualObservationSet(
        X=x_virtual,
        y=y_virtual,
        noise_std=noise,
        labels=labels,
    )

AugmentedTrainingData dataclass

Training data after appending virtual physics observations.

Source code in matgpr/physics_constraints.py
185
186
187
188
189
190
191
192
@dataclass(frozen=True)
class AugmentedTrainingData:
    """Training data after appending virtual physics observations."""

    X: pd.DataFrame | np.ndarray
    y: np.ndarray
    alpha: np.ndarray | None
    labels: np.ndarray

combine_virtual_observations(*virtual_observations)

Combine compatible virtual-observation sets into one set.

Source code in matgpr/physics_constraints.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
def combine_virtual_observations(
    *virtual_observations: VirtualObservationSet,
) -> VirtualObservationSet:
    """Combine compatible virtual-observation sets into one set."""
    sets = _flatten_virtual_observation_sets(virtual_observations)
    if not sets:
        raise ValueError("At least one VirtualObservationSet is required")

    n_features = sets[0].n_features
    for observation_set in sets:
        if observation_set.n_features != n_features:
            raise ValueError("All virtual observation sets must have the same number of features")

    x_combined = np.vstack([observation_set.X for observation_set in sets])
    y_combined = np.concatenate([observation_set.y for observation_set in sets])
    labels = np.concatenate([observation_set.labels for observation_set in sets])

    noise = None
    if any(observation_set.noise_std is not None for observation_set in sets):
        noise = np.concatenate(
            [
                observation_set.noise_std
                if observation_set.noise_std is not None
                else np.zeros(observation_set.n_observations, dtype=float)
                for observation_set in sets
            ]
        )

    return VirtualObservationSet(X=x_combined, y=y_combined, noise_std=noise, labels=labels)

append_virtual_observations(X, y, *virtual_observations, base_alpha=1e-08, observed_label='observed')

Append virtual physics observations to a training dataset.

Parameters:

Name Type Description Default
X

Real training features and targets.

required
y

Real training features and targets.

required
virtual_observations VirtualObservationSet

One or more :class:VirtualObservationSet objects.

()
base_alpha float | Sequence[float] | ndarray | None

Observation variance for real training rows. If supplied, the returned alpha vector can be passed to scikit-learn's GaussianProcessRegressor. Virtual rows use their own noise_std**2 values.

1e-08
observed_label str

Label assigned to real training rows in the returned metadata.

'observed'
Source code in matgpr/physics_constraints.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
def append_virtual_observations(
    X,
    y,
    *virtual_observations: VirtualObservationSet,
    base_alpha: float | Sequence[float] | np.ndarray | None = 1e-8,
    observed_label: str = "observed",
) -> AugmentedTrainingData:
    """Append virtual physics observations to a training dataset.

    Parameters
    ----------
    X, y
        Real training features and targets.
    virtual_observations
        One or more :class:`VirtualObservationSet` objects.
    base_alpha
        Observation variance for real training rows. If supplied, the returned
        ``alpha`` vector can be passed to scikit-learn's
        ``GaussianProcessRegressor``. Virtual rows use their own
        ``noise_std**2`` values.
    observed_label
        Label assigned to real training rows in the returned metadata.
    """
    x_values, columns = _as_numeric_matrix(X, "X")
    y_values = to_1d_finite(y, "y")
    validate_same_row_count(x_values, y_values, "X", "y")

    sets = _flatten_virtual_observation_sets(virtual_observations)
    for observation_set in sets:
        if observation_set.n_features != x_values.shape[1]:
            raise ValueError("Virtual observations must have the same number of features as X")

    x_parts = [x_values, *[observation_set.X for observation_set in sets]]
    y_parts = [y_values, *[observation_set.y for observation_set in sets]]

    x_augmented = np.vstack(x_parts)
    y_augmented = np.concatenate(y_parts)
    labels = np.concatenate(
        [
            np.full(y_values.shape[0], observed_label, dtype=object),
            *[observation_set.labels for observation_set in sets],
        ]
    )

    alpha = _augmented_alpha(base_alpha, y_values.shape[0], sets)
    if columns is None:
        x_output = x_augmented
    else:
        x_output = pd.DataFrame(x_augmented, columns=columns)

    return AugmentedTrainingData(
        X=x_output,
        y=y_augmented,
        alpha=alpha,
        labels=labels,
    )