Conditions#

When we covered The Inversion class we made use of the stopping_criterion argument that the Inversion uses to determine if it should finish or continue with the next iteration.

The stopping_criterion can be a simple function (or any callable) that takes the model as argument and returns a bool variable (True or False).

For example, the following function that checks if a given model is close to zero would be a valid argument:

def is_close_to_zero(model):
   if np.all(np.abs(model) <= 1e-13):
      return True
   return False

But in most situations, the stopping criteria for an inversion will not only depend on the current model, but on the values of certain functions when they are evaluated on the current model.

For such cases, the new framework offers a inversion_ideas.base.Condition class that implements a __call__() method that takes the model as argument and returns either True or False depending if the desired conditions is met or not. For example, the inversion_ideas.conditions.ChiTarget holds a DataMisfit object, and everytime its inversion_ideas.conditions.ChiTarget.__call__() method gets called, it will evaluate the data misfit term, get its \(\chi\) factor, and return True or False if it’s below or equal to 1.

Consider the same data_misfit object we created in Objective function:

data_misfit
\[\phi_\text{d} (\mathbf{m})\]

And define a inversion_ideas.conditions.ChiTarget object for it:

import inversion_ideas as ii

condition = ii.conditions.ChiTarget(data_misfit)
condition
<inversion_ideas.conditions.ChiTarget at 0x7f76d8d5c590>

If we evaluate it with any model that doesn’t fit the data, the \(\chi\) factor will be higher than one and therefore the condition will be False:

import numpy as np

rng = np.random.default_rng(seed=4149)
model = rng.random(size=data_misfit.n_params)
condition(model)
False
data_misfit.chi_factor(model)
np.float64(428152.94180370023)

But if we evaluate it with a model that fits the data, it will return True:

condition(true_model)
True
data_misfit.chi_factor(true_model)
np.float64(0.5791988124047548)

Combining conditions#

The Inversion takes a single callable as the stopping_criterion argument. But in some situations we might want the inversion to stop when multiple conditions are satisfied at the same time, or when at least one of them is. For those cases, we can combine Condition objects together through the logical binary operators & (AND), | (OR), and ^ (XOR).

For example, consider this very simple Condition classes:

import numpy as np
import inversion_ideas as ii

class IsPositive(ii.base.Condition):
   """Simple condition that checks if every value in the model is positive."""

   def __call__(self, model) -> bool:
      return bool(np.all(model > 0))

class IsEven(ii.base.Condition):
   """Simple condition that checks if every value in the model is even."""

   def __call__(self, model) -> bool:
      return bool(np.all(model % 2 == 0))

Note

These classes are presented for learning purposes, not intended to be used in real-world inversions.

Let’s create one instance of each:

is_positive = IsPositive()
is_even = IsEven()

And see how they work with a given model:

is_positive(np.array([1., 2., 3.]))
True
is_positive(np.array([1., -2., 3.]))
False
is_even(np.array([2., 4., 6.]))
True
is_even(np.array([1., 4., 6.]))
False

The & (AND) operator#

Let’s combine them through the & (AND) operator:

condition = is_positive & is_even

This condition will be True only if all values in the model are positive and even:

condition(np.array([2., 4., 6.]))
True

But it will fail if at least one of them doesn’t satisfy both conditions:

# A single odd number in the model
condition(np.array([1., 4., 6.]))
False
# A single non-positive number in the model
condition(np.array([2., -4., 6.]))
False
# A non-positive and an odd number in the model
condition(np.array([2., -4., 7.]))
False

The ~ (OR) operator#

Analougsly, we can combine them with an | (OR) operator:

condition = is_positive | is_even

If at least one of the two conditions is satisfied, the condition will return True:

# All positive numbers in the model
condition(np.array([1., 3., 5.]))
True
# All even numbers in the model
condition(np.array([2., 4., 6.]))
True

But if either of them is satisfied, it will return False:

condition(np.array([-1., 4., 6.]))
False

The ^ (XOR) operator#

We can also combine them using the exclusive or operator (^):

condition = is_positive ^ is_even

Similarly to the | operator, if both conditions are False, then condition will also be False:

condition(np.array([-1., 4., 6.]))
False

And if one of them is True and the other one is False, then it will be True:

# All positive numbers in the model (but not all even)
condition(np.array([1., 2., 3.]))
True
# All even numbers in the model (but not all positive)
condition(np.array([0., 2., 4.]))
True

But if both are True, then condition will be False:

# All even numbers in the model (but not all positive)
condition(np.array([2., 4., 6.]))
False

Combining multiple conditions#

We can also combine multiple conditions with these operators. For example, let’s consider a third condition class:

class IsCloseToZero(ii.base.Condition):
   """Simple condition that checks if every value in the model is close to zero."""

   def __call__(self, model) -> bool:
      return bool(np.all(np.abs(model) <= 1e-13))


is_close_to_zero = IsCloseToZero()

And combine the three of them:

condition = (is_positive & is_even) | is_close_to_zero

Hint

We can use parenthesis to group together logical operations.

This new condition will be True if all values in the model are positive and even, or if they are all close to zero.

model = np.array([2., 4., 6.])
condition(model)
True
model = np.array([1e-14, -2e-15, 0.])
condition(model)
True

But it will fail otherwise:

model = np.array([1., 2., 0.])
condition(model)
False

Showing information#

We can see more detailed information about a composed conditions to see which one of the internal conditions is being satisfied and which one is not. For that we can use the inversion_ideas.base.Condition.info() method:

model = np.array([2., 4., 6.])
condition.info(model)
[x] LogicalOr
├── [x] LogicalAnd
│   ├── ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│   │   │ [x] IsPositive                                                                                          │
│   │   ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯
│   └── ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│       │ [x] IsEven                                                                                              │
│       ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯
└── ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
    │ [ ] IsCloseToZero                                                                                           │
    ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Real-world use cases#

Some common use case for combining conditions is to force the inversion to stop if the \(\chi\) factor of the data misfit term reaches the target or if the whole objective function hasn’t changed significantly since the previous iteration. We can then combine a inversion_ideas.conditions.ChiTarget and a inversion_ideas.conditions.ObjectiveChanged:

# Define the objective function
smallness = ii.SimpleSmallness(n_params)
phi = data_misfit + 0.2 * smallness

# Define the two conditions
chi_target = ii.conditions.ChiTarget(data_misfit)
objective_changed = ii.conditions.ObjectiveChanged(phi, rtol=1e-2)

# Combine them into a single stopping criterion
stopping_criterion = chi_target | objective_changed