Surrogate models - Advanced features

A deeper dive into meta-models created by make_surrogate.

Update of sub-system variables at each surrogate model execution

As discussed in the tutorial on surrogate models, a meta-model, when activated, supersedes the behaviour of a system as originally defined by its compute() method. The meta-model is a black box whose inputs are specified by the Design of Experiment (DOE) on which it was trained. But what about its outputs?

At the very least, the output of a system surrogate should match those of the original system. CoSApp runs the extra mile, by adding all outputs and connected inputs of all sub-systems to the list of meta-model outputs. Moreover, at each surrogate model execution, all sub-system variables are synchronized with the computed meta-model outputs.

This way, the meta-modeled system and its sub-systems are kept in a state consistent with its free inputs.

Consider the systems defined in the tutorial on surrogate models:

[1]:
from cosapp.base import System


class MultiplyByM(System):
    """System computing y = m * x"""
    def setup(self):
        self.add_inward('m', 1.0)
        self.add_inward('x')
        self.add_outward('y')

    def compute(self):
        self.y = self.m * self.x


class AffineSystem(System):
    """System computing y = a.m * b.m * x + 3"""
    def setup(self):
        self.add_outward('y')
        # sub-systems
        a = self.add_child(MultiplyByM('a'), pulling='x')
        b = self.add_child(MultiplyByM('b'))
        # connections
        self.connect(a, b, {'y': 'x'})

    def compute(self):
        self.y = self.b.y + 3

    @property
    def y_exact(self) -> float:
        """Expected value for `self.y`"""
        return self.b.m * (self.a.m * self.x) + 3
[2]:
import pandas
import itertools

def Cartesian_DoE(axes: dict) -> pandas.DataFrame:
    """Simple Cartesian grid DoE from 1D samples in all axis directions"""
    return pandas.DataFrame(
        itertools.product(*axes.values()),
        columns=axes.keys(),
    )
[3]:
import numpy

axes = {
    'x': numpy.linspace(-5, 5, 11),
    'a.m': numpy.linspace(-3, 3, 6),
    'b.m': numpy.linspace(-3, 3, 6),
}

doe = Cartesian_DoE(axes)

head = AffineSystem('head')
meta = head.make_surrogate(doe)

print(
    f"Outputs of {head.name!r}:",
    dict(filter(lambda item: len(item[1]) > 0, head.outputs.items())),  # non-empty output ports
    f"\nOutputs of meta-model:",
    meta.synched_outputs,
    sep='\n',
)
Outputs of 'head':
{'outwards': ExtensiblePort: {'y': 48.0}}

Outputs of meta-model:
['outwards.y', 'a.inwards.x', 'a.outwards.y', 'b.inwards.x', 'b.outwards.y']

As can be seen in previous cell, system head has only one output y, whereas meta has five, the last four of which are sub-system variables. Every time head is executed, its meta-model not only updates head.y, but also head.a and head.b variables, as would be the case with the original system.

In cell below, we show the internal state of system head with or without meta-model bypass.

[4]:
def run_and_print(head: AffineSystem, activate: bool):
    inputs = ['x']
    outputs = ['y', 'a.x', 'a.y', 'b.x', 'b.y']
    for var in outputs:
        head[var] = -999.999  # set to bogus value
    # Set surrogate status and run model
    head.active_surrogate = activate
    head.run_once()
    # Print info on internal variables
    print('\nActivated meta-model:', head.active_surrogate)
    for var in inputs + outputs:
        print(f"{head.name}.{var}: ", head[var], sep='\t')

head.x = 3.14
head.a.m = 0.25
head.b.m = -1

run_and_print(head, activate=False)
run_and_print(head, activate=True)


Activated meta-model: False
head.x:         3.14
head.y:         2.215
head.a.x:       3.14
head.a.y:       0.785
head.b.x:       0.785
head.b.y:       -0.785

Activated meta-model: True
head.x:         3.14
head.y:         2.214842218714314
head.a.x:       3.1471118198553447
head.a.y:       0.7871162151169441
head.b.x:       0.7871162151169441
head.b.y:       -0.7851577812874428

After meta-model activation, top-level output head.y is estimated with a numerical error.

Noticeably, it also appears that internal connections are accounted for by the meta-model, as a.y and b.x are equal, for example. Moreover, the behaviour \(y = m\,x\) is also satisfied (within numerical error) for sub-systems a and b, with head.a.m = 0.25 and head.b.m = -1. Precision is rather poor here, because the meta-model was trained on scarce data.

Post-synchronization can be altered at meta-model creation, with optional argument postsynch, specifying a name pattern (or a list thereof) for variables that must be synchronized. By default, this argument is set to '*', meaning “synchronize everything”. An empty list or None value means “do not synchronize anything apart from top-level outputs”.

In the example below, we specify that only variables of sub-system head.a should be synchronized:

[5]:
head = AffineSystem('head')
meta = head.make_surrogate(doe, postsynch='a.*')

print(
    f"Outputs of meta-model:",
    meta.synched_outputs,
    sep='\n',
)
head.x = 3.14
head.a.m = 0.25
head.b.m = -1

run_and_print(head, activate=False)
run_and_print(head, activate=True)

Outputs of meta-model:
['y', 'a.y']

Activated meta-model: False
head.x:         3.14
head.y:         2.215
head.a.x:       3.14
head.a.y:       0.785
head.b.x:       0.785
head.b.y:       -0.785

Activated meta-model: True
head.x:         3.14
head.y:         2.2144689816859966
head.a.x:       3.14
head.a.y:       0.7873347384770749
head.b.x:       -999.999
head.b.y:       -999.999

As requested, we observe that ports b.x and b.y have not been modified after meta-model execution.

Limiting post-synchronization may also help reduce training time.

Training a sub-system

Methods

  • make_surrogate

  • dump_surrogate

  • load_surrogate

  • active_surrogate

can be called at any system level.

Systems above the meta-modeled module (i.e. parent systems) will be unaffected by the procedure. Systems below (child systems), on the other hand, will automatically be deactivated, together with their drivers, if any.

In the example below, we create a meta-model on sub-system head.a alone:

[6]:
head = AffineSystem('head')

axes = {
    'm': numpy.linspace(-2, 2, 11),
    'x': numpy.linspace(-10, 10, 21),
}

doe = Cartesian_DoE(axes)
head.a.make_surrogate(doe, activate=False)  # create meta-model, but do not activate it

# Sanity check:
print(
    f"{head.has_surrogate = }",
    f"{head.a.has_surrogate = }",
    "",
    sep="\n",
)

head.x = 5.2
head.a.m = 1.55

head.run_drivers()
print(f"{head.a.active_surrogate = }; \t{head.y = }")

head.a.active_surrogate = True
head.run_drivers()
print(f"{head.a.active_surrogate = }; \t{head.y = }")
head.has_surrogate = False
head.a.has_surrogate = True

head.a.active_surrogate = False;        head.y = 11.06
head.a.active_surrogate = True;         head.y = 11.060051256302355

Impact of training space

What happens when the meta-model is trained on a subset of inputs?

In the example below, we again create a meta-model for sub-system head.a, but do not include input m in the DOE:

[7]:
head = AffineSystem('head')
head.a.m = 1.55

axes = {
    # 'm': numpy.linspace(-2, 2, 11),  # do not train on `m`
    'x': numpy.linspace(-10, 10, 21),
}

doe = Cartesian_DoE(axes)
head.a.make_surrogate(doe, activate=False)  # create meta-model, but do not activate it

head.x = 5.2

head.run_drivers()
print(f"{head.a.active_surrogate = }; \t{head.y = }")

head.a.active_surrogate = True
head.run_drivers()
print(f"{head.a.active_surrogate = }; \t{head.y = }")
head.a.active_surrogate = False;        head.y = 11.06
head.a.active_surrogate = True;         head.y = 11.060211519142264

Here, the surrogate model was trained assuming head.a.m is constant and equals 1.55. The result after activation is correct, as head.a.m was unchanged.

However, results are incorrect when this parameter is modified:

[8]:
head.a.m = 0.83

head.run_drivers()
print(f"{head.y = } instead of {head.y_exact}")
head.y = 11.060211519142264 instead of 7.316

Tip: Before creating a system surrogate, be sure to lookup variables that may have an impact on outputs:

[9]:
soi = head.a  # system of interest

print(
    "Input ports:", dict(soi.inputs),
    "",
    f"{list(soi.unknowns) = }",
    f"{list(soi.transients) = }",
    sep="\n",
)
Input ports:
{'inwards': ExtensiblePort: {'m': 0.83, 'x': 5.2}, 'modevars_in': ModeVarPort: {}}

list(soi.unknowns) = []
list(soi.transients) = []

Be aware that this technique does not report sub-system inputs that may influence outputs, though.

Surrogate models and solvers

Good practices when the system of interest has unknowns.

Consider a class ClosedSystem, assembling one sub-system usys (of type UnknownSystem) bearing an unknown, and one sub-system esys (of type EquationSystem) containing an equation.

[10]:
class UnknownSystem(System):
    def setup(self):
        self.add_inward('m', 1.0, desc="Multiplying factor")
        self.add_inward('x', 1.0)
        self.add_outward('y')
        # Unknowns
        self.add_unknown('m')

    def compute(self):
        self.y = self.x * self.m

class EquationSystem(System):
    def setup(self, p_target=25):
        self.add_inward('p', 1.0)
        self.add_property('p_target', p_target)
        self.add_equation("p == p_target")

class ClosedSystem(System):
    def setup(self):
        usys = self.add_child(UnknownSystem('usys'), pulling='x')
        esys = self.add_child(EquationSystem('esys'))

        self.connect(usys, esys, {'y': 'p'})

    @property
    def solution(self) -> float:
        """Exact solution for self.usys.m"""
        try:
            return self.esys.p_target / self.x

        except ZeroDivisionError:
            return numpy.sign(self.esys.p_target) * numpy.inf
[11]:
from cosapp.drivers import NonLinearSolver
from cosapp.utils.surrogate_models import (
    FloatKrigingSurrogate,
    LinearNearestNeighbor,
)

Case 1: The system of interest is well-posed

In this part, the system of interest is the top assembly, mathematically closed.

If the system has a NonLinearSolver driver before the generation of the meta-model, CoSApp automatically tracks unknowns, and you don’t have to add them to the input training dataset.

[12]:
head = ClosedSystem("head")
head.add_driver(NonLinearSolver('solver', tol=1e-9))
# Check that assembly system + driver can be solved
print(f"{head.is_standalone() = }")

doe = Cartesian_DoE({
    'x': numpy.linspace(0.1, 10, 20),
})
meta = head.make_surrogate(doe, model=LinearNearestNeighbor)

# Check that `usys.m` is tracked by meta-model
print(f"{meta.synched_outputs = }")
head.is_standalone() = True
meta.synched_outputs = ['usys.m', 'usys.inwards.x', 'usys.outwards.y', 'esys.inwards.p']
[13]:
import plotly.graph_objects as go
from plotly.subplots import make_subplots

def make_figure(head, meta=None, x_values=numpy.linspace(3, 10, 21)):
    """
    Utility function to compare the behaviour of a `ClosedSystem`
    instance `head` with and without meta-modeling on head[meta].
    """
    fig = make_subplots(
        rows=1,
        cols=2,
        subplot_titles=("Solution", "Relative difference"),
    )

    system = head if meta is None else head[meta]
    meta_results = numpy.zeros_like(x_values)
    normal_results = numpy.zeros_like(x_values)

    for k, head.x in enumerate(x_values):
        system.active_surrogate = True
        head.run_drivers()
        meta_results[k] = head.usys.m

        system.active_surrogate = False
        head.run_drivers()
        normal_results[k] = head.usys.m

    ############################### BUILDING FIGURE ###############################

    def add_scatter(figure, row, col, y, **options):
        figure.add_trace(
            go.Scatter(x=x_values, y=y, **options),
            row=row, col=col,
        )

    add_scatter(fig, 1, 1, meta_results,
        name='Meta-model on',
    )
    add_scatter(fig, 1, 1, normal_results,
        name='Meta-model off',
        mode='markers',
    )
    add_scatter(fig, 1, 2,
        y = numpy.absolute(meta_results / normal_results - 1),
        name='Relative diff',
        mode='markers',
    )

    ############################### GRAPH LAYOUT ###############################
    sysname = f"{head.name}" if meta is None else f"{head.name}.{system.name}"
    fig.update_layout(
        title=dict(
            text=f"Solution with and without meta-model on {sysname!r}",
            y=0.95,
            x=0.5,
            xanchor='center',
            yanchor='top',
        ),
        legend_title_text='Legend:',
        hovermode='x',
    )
    fig.update_xaxes(title_text="head.x", row=1, col=1)
    fig.update_xaxes(title_text="head.x", row=1, col=2)
    fig.update_yaxes(title_text="head.usys.m", row=1, col=1)
    fig.update_yaxes(title_text="|meta / normal - 1|", row=1, col=2)
    fig.update_traces(showlegend=True)

    return fig

[14]:
fig = make_figure(head)
fig.show()

Case 2: The system of interest is ill-posed

Here, we assume that the system of interest has an unbalanced number of unknowns and equations, such that it cannot be solved on its own.

This is the case of sub-system usys of assembly ClosedSystem defined above.

[15]:
head = ClosedSystem("head")
head.add_driver(NonLinearSolver('solver', tol=1e-9))

doe = Cartesian_DoE({
    'm': numpy.linspace(-10, 10, 21),
    'x': numpy.linspace(-10, 10, 20),
})
head.usys.make_surrogate(doe, activate=False)

print(
    f"{head.is_standalone() = }",   # top assembly is solvable
    f"{head.usys.is_standalone() = }",  # sub-system `usys` is not
    sep="\n",
)

head.is_standalone() = True
head.usys.is_standalone() = False
[16]:
head.x = 4.

print(f"Exact solution: head.usys.m = {head.solution}")

def solve_and_print(activate: bool):
    head.usys.active_surrogate = activate
    head.run_drivers()
    print(
        f"\nMeta-model activated: {head.usys.active_surrogate}",
        f"{head.usys.m = }",
        f"Error: {head.esys.p - head.esys.p_target = }",
        sep="\n",
    )

solve_and_print(activate=False)
solve_and_print(activate=True)
Exact solution: head.usys.m = 6.25

Meta-model activated: False
head.usys.m = 6.25
Error: head.esys.p - head.esys.p_target = 0.0

Meta-model activated: True
head.usys.m = 6.250589017670577
Error: head.esys.p - head.esys.p_target = -2.27018404075352e-12
[17]:
fig = make_figure(head, meta='usys')
fig.show()

Depending on model type (FloatKrigingSurrogate, LinearNearestNeighbor…), the training precision for your unknowns will vary. If the DoE space is not sufficiently large, NonLinearSolver may fail.

As a general rule of thumb, it is better to have an idea of the solution, so you can train in a range where NonLinearSolver is not likely to fail.

Common Pitfall

A system unknown is left out of the training set:

[18]:
head = ClosedSystem("head")

doe = Cartesian_DoE({
    # 'm': numpy.linspace(-10, 10, 21),  # `m` left out on purpose
    'x': numpy.linspace(-10, 10, 21),
})

head.usys.make_surrogate(doe)

print(f"{head.usys.active_surrogate = }")
head.usys.active_surrogate = True
/home/docs/checkouts/readthedocs.org/user_builds/cosapp/conda/latest/lib/python3.11/site-packages/cosapp/systems/systemSurrogate.py:348: UserWarning:

The following unknowns/transients are not part of the training set; future attempts to compute them with a driver may fail: ['m']

Since m is not part of the training set, the surrogate model cannot predict its impact on outputs.

As a consequence, NonLinearSolver will fail:

[19]:
head.add_driver(NonLinearSolver('solver', tol=1e-9))

head.x = 4.5
head.run_drivers()
/home/docs/checkouts/readthedocs.org/user_builds/cosapp/conda/latest/lib/python3.11/site-packages/cosapp/core/numerics/root.py:502: LinAlgWarning:

Diagonal number 1 is exactly zero. Singular matrix.

The solver failed: Singular 1x1 Jacobian matrix
The 1 following parameter(s) have no influence: ['m']
The 1 following residue(s) are not influenced: ['esys: p == p_target']