Skip to content

Commit d9758fa

Browse files
ShiftScaleTransformer Scaling Options "maxnorm" and "maxnormsym" (#74)
* option "maxnorm" for snapshot transformers including tests * small doc/test fixes, move byrow/scaling conflict check to __init__() * ShiftScaleTransformer(scaling='maxnormsym') * update pre docs page * bump version 0.5.10 -> 0.5.11, update changelog --------- Co-authored-by: Shane <[email protected]>
1 parent c2fa327 commit d9758fa

File tree

7 files changed

+175
-22
lines changed

7 files changed

+175
-22
lines changed

docs/source/api/pre.ipynb

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"::::{admonition} Example Data\n",
6161
":class: tip\n",
6262
"\n",
63-
"The examples on this page use data from the combustion problem described in {cite}`swischuk2020combustion`.\n",
63+
"The examples on this page use data downsampled from the combustion problem described in {cite}`swischuk2020combustion`.\n",
6464
"\n",
6565
":::{dropdown} State Variables\n",
6666
"\n",
@@ -73,8 +73,8 @@
7373
"- Specific volume (inverse density) $\\xi = 1/\\rho$\n",
7474
"- Chemical species molar concentrations for CH$_{4}$, O$_{2}$, CO$_{2}$, and H$_{2}$O.\n",
7575
"\n",
76-
"The dimension of the spatial discretization in the full example in {cite}`swischuk2020combustion` is $38{,}523$ per variable, so $n = 9 \\times 38{,}523 = 346{,}707$.\n",
77-
"Here we have downsampled the state dimension to $535$ for each variable for demonstration purposes, i.e., $n = 9 \\times 535 = 4{,}815$.\n",
76+
"The dimension of the spatial discretization in the full example in {cite}`swischuk2020combustion` is $n_x = 38{,}523$ for each of the $n_q = 9$ variables, so the total state dimension is $n_q n_x = 9 \\times 38{,}523 = 346{,}707$.\n",
77+
"For demonstration purposes, we have downsampled the state dimension to $n_x' = 535$, hence $n = n_q n_x' = 9 \\times 535 = 4{,}815$ is the total state dimension of the example data.\n",
7878
":::\n",
7979
"\n",
8080
"You can [download the data here](https://github.com/Willcox-Research-Group/rom-operator-inference-Python3/raw/data/pre_example.npy) to repeat the experiments.\n",
@@ -84,7 +84,7 @@
8484
},
8585
{
8686
"cell_type": "code",
87-
"execution_count": 1,
87+
"execution_count": null,
8888
"metadata": {},
8989
"outputs": [],
9090
"source": [
@@ -139,6 +139,57 @@
139139
":::"
140140
]
141141
},
142+
{
143+
"cell_type": "markdown",
144+
"metadata": {},
145+
"source": [
146+
"::::{admonition} Fit-and-Transform versus Transform\n",
147+
":class: important\n",
148+
"\n",
149+
"Pre-processing transformation classes are calibrated through user-provided hyperparameters in the constructor and/or training snapshots passed to ``fit()`` or ``fit_transform()``.\n",
150+
"The ``transform()`` method applies but *does not alter* the transformation.\n",
151+
"Some transformations are designed so that the transformed training data has certain properties, but those properties are not guaranteed to hold for transformed data that was not used for training.\n",
152+
"\n",
153+
":::{dropdown} Example\n",
154+
"\n",
155+
"Consider a set of training snapshots $\\{\\q_{j}\\}_{j=0}^{k-1}\\subset\\RR^n$.\n",
156+
"The {class}`ShiftScaleTransformer` can shift data by the mean training snapshot, meaning it can represent the transformation $\\mathcal{T}:\\RR^{n}\\to\\RR^{n}$ given by\n",
157+
"\n",
158+
"$$\n",
159+
"\\begin{aligned}\n",
160+
" \\mathcal{T}(\\q) = \\q - \\bar{\\q},\n",
161+
" \\qquad\n",
162+
" \\bar{\\q} = \\frac{1}{k}\\sum_{j=0}^{k-1}\\q_{j}.\n",
163+
"\\end{aligned}\n",
164+
"$$\n",
165+
"\n",
166+
"The key property of this transformation is that the transformed training snapshots have zero mean.\n",
167+
"That is,\n",
168+
"\n",
169+
"$$\n",
170+
"\\begin{aligned}\n",
171+
" \\frac{1}{k}\\sum_{j=0}^{k-1}\\mathcal{T}(\\q_j)\n",
172+
" = \\frac{1}{k}\\sum_{j=0}^{k-1}(\\q_j - \\bar{\\q})\n",
173+
" = \\frac{1}{k}\\sum_{j=0}^{k-1}\\q_j - \\frac{1}{k}\\sum_{j=0}^{k-1}\\bar{\\q}\n",
174+
" = \\bar{\\q} - \\frac{k}{k}\\bar{\\q}\n",
175+
" = \\0.\n",
176+
"\\end{aligned}\n",
177+
"$$\n",
178+
"\n",
179+
"However, for any other collection $\\{\\mathbf{x}_j\\}_{j=0}^{k'-1}\\subset\\RR^{n}$ of snapshots, the set of transformed snapshots $\\{\\mathcal{T}(\\mathbf{x}_j)\\}_{j=0}^{k'-1}$ is not guaranteed to have zero mean because $\\mathcal{T}$ shifts by the mean of the $\\q_j$'s, not the mean of the $\\mathbf{x}_j$'s.\n",
180+
"That is,\n",
181+
"\n",
182+
"$$\n",
183+
"\\begin{aligned}\n",
184+
" \\frac{1}{k'}\\sum_{j=0}^{k'-1}\\mathcal{T}(\\mathbf{x}_j)\n",
185+
" = \\frac{1}{k'}\\sum_{j=0}^{k'-1}(\\mathbf{x}_j - \\bar{\\q})\n",
186+
" \\neq \\0.\n",
187+
"\\end{aligned}\n",
188+
"$$\n",
189+
":::\n",
190+
"::::"
191+
]
192+
},
142193
{
143194
"cell_type": "markdown",
144195
"metadata": {},
@@ -214,7 +265,7 @@
214265
"cell_type": "markdown",
215266
"metadata": {},
216267
"source": [
217-
"The most common type of shift sets the reference snapshot to be the average of the training snapshots:\n",
268+
"One strategy that is often effective for Operator Inference is to set the reference snapshot to be the average of the training snapshots:\n",
218269
"\n",
219270
"$$\n",
220271
" \\bar{\\q}\n",
@@ -352,7 +403,7 @@
352403
"metadata": {},
353404
"source": [
354405
"Many engineering problems feature multiple variables with ranges across different scales.\n",
355-
"For such cases, it is often beneficial to scale the variables to similar ranges so that one variable does not overwhelm the other in the operator learning.\n",
406+
"For such cases, it is often beneficial to scale the variables to similar ranges so that one variable does not overwhelm the other during operator learning.\n",
356407
"In other words, training data should be nondimensionalized when possible.\n",
357408
"\n",
358409
"A scaling operation for a single variable is given by\n",
@@ -362,7 +413,7 @@
362413
"$$\n",
363414
"\n",
364415
"where $\\alpha \\neq 0$ and $\\q'$ is a training snapshot after shifting (when desired).\n",
365-
"The :class:`ScaleTransformer` class receives a scaler $\\alpha$ and implements this transformation."
416+
"The {class}`ScaleTransformer` class receives a scaler $\\alpha$ and implements this transformation."
366417
]
367418
},
368419
{
@@ -668,7 +719,7 @@
668719
},
669720
{
670721
"cell_type": "code",
671-
"execution_count": 25,
722+
"execution_count": null,
672723
"metadata": {},
673724
"outputs": [],
674725
"source": [
@@ -738,7 +789,7 @@
738789
},
739790
{
740791
"cell_type": "code",
741-
"execution_count": 26,
792+
"execution_count": null,
742793
"metadata": {},
743794
"outputs": [],
744795
"source": [
@@ -806,7 +857,7 @@
806857
},
807858
{
808859
"cell_type": "code",
809-
"execution_count": 27,
860+
"execution_count": null,
810861
"metadata": {},
811862
"outputs": [],
812863
"source": [
@@ -832,7 +883,7 @@
832883
":class: note\n",
833884
"\n",
834885
"- In this example, the `state_dimension` could be set in the constructor because the `w` argument is a vector of length $n$. However, the `state_dimension` is not required to be set until [`fit_transform()`](TransformerTemplate.fit_transform).\n",
835-
"- Because the transformation is dictated by the choice of `\\w` and not calibrated from data, [`fit_transform()`](TransformerTemplate.fit_transform) simply calls [`transform()`](TransformerTemplate.transform).\n",
886+
"- Because the transformation is dictated by the choice of `w` and not calibrated from data, [`fit_transform()`](TransformerTemplate.fit_transform) simply calls [`transform()`](TransformerTemplate.transform).\n",
836887
"- When `locs` is provided in [`inverse_transform()`](TransformerTemplate.inverse_transform), it is assumed that the `states_transformed` are the elements of the state vector at the given locations. That is,`inverse_transform(transform(states)[locs], locs) == states[locs]`.\n",
837888
":::"
838889
]

docs/source/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
[![PyPI](https://img.shields.io/pypi/wheel/opinf)](https://pypi.org/project/opinf/)
99

1010
:::{attention}
11-
This documentation is for `opinf` version `0.5`, which introduced major changes from the previous version `0.4.5`.
11+
This documentation is for `opinf` version `0.5.x`, which introduced major changes from the previous version `0.4.5`.
1212
See updates and notes for old versions [here](./opinf/changelog.md).
1313
:::
1414

docs/source/opinf/changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
New versions may introduce substantial new features or API adjustments.
66
:::
77

8+
## Version 0.5.11
9+
10+
- New scaling option for ``pre.ShiftScaleTransformer`` so that training snapshots have at maximum norm 1. Contributed by [@nicolearetz](https://github.com/nicolearetz).
11+
- Small clarifications to ``pre.ShiftScaleTransformer`` and updates to the ``pre`` documentation.
12+
813
## Version 0.5.10
914

1015
New POD basis solver option `basis.PODBasis(solver="method-of-snapshots")` (or `solver="eigh"`), which solves a symmetric eigenvalue problem instead of computing a (weighted) SVD. This method is more efficient than the SVD for snapshot matrices $\mathbf{Q}\in\mathbb{R}^{n\times k}$ where $n \gg k$ and is significantly more efficient than the SVD when a non-diagonal weight matrix is provided.

src/opinf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
https://github.com/Willcox-Research-Group/rom-operator-inference-Python3
88
"""
99

10-
__version__ = "0.5.10"
10+
__version__ = "0.5.11"
1111

1212
from . import (
1313
basis,

src/opinf/pre/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# pre/__init__.py
2-
"""Tools for preprocessing snapshot data prior to compression."""
2+
"""Tools for preprocessing snapshot data after (optional) lifting but prior to
3+
compression.
4+
"""
35

46
from ._base import *
57
from ._multi import *

src/opinf/pre/_shiftscale.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -652,7 +652,8 @@ def load(cls, loadfile):
652652

653653

654654
class ShiftScaleTransformer(TransformerTemplate):
655-
r"""Process snapshots by centering and/or scaling (in that order).
655+
r"""Process snapshots by vector centering and/or affine scaling
656+
(in that order).
656657
657658
Transformations with this class are notated below as
658659
@@ -664,9 +665,11 @@ class ShiftScaleTransformer(TransformerTemplate):
664665
665666
where :math:`\Q\in\RR^{n \times k}` is the snapshot matrix to be
666667
transformed and :math:`\Q''\in\RR^{n \times k}` is the transformed snapshot
667-
matrix.
668+
matrix. Transformation parameters are learned from a training data set, not
669+
provided explicitly by the user as in :class:`ShiftTransformer` or
670+
:class:`ScaleTransformer`.
668671
669-
All transformations with this class are `affine` and hence can be written
672+
All transformations with this class are *affine* and hence can be written
670673
componentwise as :math:`\Q_{i,j}'' = \alpha_{i,j} \Q_{i,j} + \beta_{i,j}`
671674
for some choice of :math:`\alpha_{i,j},\beta_{i,j}\in\RR`.
672675
@@ -683,6 +686,14 @@ class ShiftScaleTransformer(TransformerTemplate):
683686
If given, scale (non-dimensionalize) the centered snapshot entries.
684687
Otherwise, :math:`\Q'' = \Q'` (default).
685688
689+
All scaling options multiply :math:`\Q'` by a constant; others
690+
(symmetric scalings, ``'standard'`` and those ending in ``'sym'``)
691+
shift the entries of :math:`\Q'` by a constant (the mean entry) as
692+
well. This is different from setting ``centering=True``, which shifts
693+
each column of :math:`\Q` by a vector; however, when ``centering=True``
694+
symmetric scaling options are equivalent to their non-symmetric
695+
counterparts because in that case the mean of :math:`\Q'` is zero.
696+
686697
**Options:**
687698
688699
.. dropdown:: ``'standard'``
@@ -757,9 +768,39 @@ class ShiftScaleTransformer(TransformerTemplate):
757768
:math:`\max_j(\text{abs}(\Q_{i,j}'')) = 1` for each row index
758769
:math:`i`
759770
771+
.. dropdown:: ``'maxnorm'``
772+
Maximum Euclidean norm scaling to :math:`[0, 1]` without
773+
scalar mean shift
774+
775+
.. list-table::
776+
777+
* - Formula
778+
- .. math:: \Q'' = \frac{1}{\max_j(\|\Q'_{:,j}\|_2)}\Q'
779+
* - ``byrow=False``
780+
- :math:`\mean(\Q'')=\frac{\mean(\Q')}{\max_j(\|\Q'_{:,j}\|)}`
781+
and :math:`\max_j(\|\Q''_{:,j}\|) = 1`
782+
* - ``byrow=True``
783+
- ``ValueError``: use ``'maxabs'`` instead
784+
785+
.. dropdown:: ``'maxnormsym'``
786+
Maximum Euclidean norm scaling to :math:`[0, 1]` with scalar mean
787+
shift
788+
789+
.. list-table::
790+
791+
* - Formula
792+
- .. math::
793+
\Q'' = \frac{\Q' - \text{mean}(\Q')}{
794+
\max_j(\|\Q'_{:,j} - \text{mean}(\Q')\|_2)}
795+
* - ``byrow=False``
796+
- :math:`\mean(\Q'')=0` and :math:`\max_j(\|\Q''_{:,j}\|) = 1`
797+
* - ``byrow=True``
798+
- ``ValueError``: use ``'maxabssym'`` instead
799+
760800
byrow : bool
761801
If ``True``, scale each row of the snapshot matrix separately when a
762-
scaling is specified. Otherwise, scale the entire matrix at once.
802+
scaling is specified. Otherwise, scale the entire matrix at once
803+
(default).
763804
764805
verbose : bool
765806
If ``True``, print information upon learning a transformation.
@@ -785,6 +826,8 @@ class ShiftScaleTransformer(TransformerTemplate):
785826
"minmaxsym",
786827
"maxabs",
787828
"maxabssym",
829+
"maxnorm",
830+
"maxnormsym",
788831
)
789832
)
790833

@@ -824,6 +867,10 @@ def __init__(
824867
"scaling=None --> byrow=True will have no effect",
825868
errors.OpInfWarning,
826869
)
870+
if self.__byrow and self.__scaling in ("maxnorm", "maxnormsym"):
871+
raise ValueError(
872+
f"scaling '{self.__scaling}' is invalid when byrow=True"
873+
)
827874

828875
# Set other properties.
829876
self.verbose = verbose
@@ -1049,15 +1096,36 @@ def fit_transform(self, states, inplace: bool = False):
10491096
0 if axis is None else np.zeros(self.state_dimension)
10501097
)
10511098

1052-
# maxabssym: Q' = (Q - mean(Q)) / max(abs(Q - mean(Q)))
1099+
# Symmetric MaxAbs: Q' = (Q - mean(Q)) / max(abs(Q - mean(Q)))
10531100
elif self.scaling == "maxabssym":
10541101
mu = np.mean(Y, axis=axis)
10551102
Y -= mu if axis is None else mu.reshape((-1, 1))
10561103
self.scale_ = 1 / np.max(np.abs(Y), axis=axis)
10571104
self.shift_ = -mu * self.scale_
10581105
Y += mu if axis is None else mu.reshape((-1, 1))
10591106

1060-
else: # pragma nocover
1107+
# MaxNorm: Q' = Q / max(norm(Q))
1108+
elif self.scaling == "maxnorm":
1109+
# scale such that the norm of each snapshot is <= 1
1110+
if self.byrow: # pragma: nocover
1111+
raise RuntimeError(
1112+
f"invalid scaling '{self.scaling}' for byrow=True"
1113+
)
1114+
1115+
self.scale_ = 1 / np.max(np.linalg.norm(Y, axis=0, ord=2))
1116+
self.shift_ = 0
1117+
1118+
# Symmetric MaxNorm: Q' = (Q - mean(Q)) / max(norm(Q - mean(Q)))
1119+
elif self.scaling == "maxnormsym":
1120+
if self.byrow: # pragma: nocover
1121+
raise RuntimeError(
1122+
f"invalid scaling '{self.scaling}' for byrow=True"
1123+
)
1124+
mu = np.mean(Y)
1125+
self.scale_ = 1 / np.max(np.linalg.norm(Y - mu, axis=0, ord=2))
1126+
self.shift_ = -mu * self.scale_
1127+
1128+
else: # pragma: nocover
10611129
raise RuntimeError(f"invalid scaling '{self.scaling}'")
10621130

10631131
# Apply the scaling.

tests/pre/test_shiftscale.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77

88
import opinf
99

10-
from .test_base import _TestTransformer
10+
try:
11+
from .test_base import _TestTransformer
12+
except ImportError:
13+
from test_base import _TestTransformer
1114

1215

1316
# Functions ===================================================================
@@ -158,7 +161,8 @@ def get_transformers(self, name=None):
158161
verbose=False,
159162
)
160163
self.requires_training = True
161-
if scaling is not None:
164+
if scaling is not None and "maxnorm" not in scaling:
165+
# "maxnorm" scaling is incompatible with byrow=True
162166
yield self.Transformer(
163167
centering=centering,
164168
scaling=scaling,
@@ -361,6 +365,17 @@ def fit_transform_copy(st, A):
361365
assert np.isclose(np.mean(Y), 0)
362366
assert np.isclose(np.max(np.abs(Y)), 1)
363367

368+
# Test maximum norm scaling.
369+
st = self.Transformer(centering=centering, scaling="maxnorm")
370+
Y = fit_transform_copy(st, X)
371+
assert np.isclose(np.max(np.linalg.norm(Y, axis=0)), 1)
372+
373+
# Test symmetric maximum norm scaling.
374+
st = self.Transformer(centering=centering, scaling="maxnormsym")
375+
Y = fit_transform_copy(st, X)
376+
assert np.isclose(np.mean(Y), 0)
377+
assert np.isclose(np.max(np.linalg.norm(Y, axis=0)), 1)
378+
364379
# Test scaling by row (without and with centering).
365380
for centering in (False, True):
366381
# Test standard scaling.
@@ -414,6 +429,18 @@ def fit_transform_copy(st, A):
414429
assert np.allclose(np.mean(Y, axis=1), 0)
415430
assert np.allclose(np.max(np.abs(Y), axis=1), 1)
416431

432+
# Test norm scaling.
433+
for s in "maxnorm", "maxnormsym":
434+
with pytest.raises(ValueError) as ex:
435+
self.Transformer(
436+
centering=centering,
437+
scaling=s,
438+
byrow=True,
439+
)
440+
assert ex.value.args[0] == (
441+
f"scaling '{s}' is invalid when byrow=True"
442+
)
443+
417444
def test_mains(self, n=11, k=21):
418445
"""Test fit(), fit_transform(), transform(), transform_ddts(), and
419446
inverse_transform().

0 commit comments

Comments
 (0)