flopscope 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benchmarks/__init__.py +1 -0
- benchmarks/__main__.py +6 -0
- benchmarks/_baseline.py +171 -0
- benchmarks/_bitwise.py +231 -0
- benchmarks/_complex.py +176 -0
- benchmarks/_contractions.py +291 -0
- benchmarks/_fft.py +198 -0
- benchmarks/_impl_urls.py +139 -0
- benchmarks/_linalg.py +197 -0
- benchmarks/_linalg_delegates.py +407 -0
- benchmarks/_metadata.py +141 -0
- benchmarks/_misc.py +653 -0
- benchmarks/_perf.py +321 -0
- benchmarks/_perm_group_calibration.py +175 -0
- benchmarks/_pointwise.py +372 -0
- benchmarks/_polynomial.py +193 -0
- benchmarks/_random.py +209 -0
- benchmarks/_reductions.py +136 -0
- benchmarks/_sorting.py +289 -0
- benchmarks/_stats.py +137 -0
- benchmarks/_window.py +92 -0
- benchmarks/accumulation/__init__.py +0 -0
- benchmarks/accumulation/bench_cost_compute.py +138 -0
- benchmarks/dashboard.py +312 -0
- benchmarks/runner.py +636 -0
- flopscope/__init__.py +273 -0
- flopscope/_accumulation/__init__.py +13 -0
- flopscope/_accumulation/_bipartite.py +121 -0
- flopscope/_accumulation/_burnside.py +51 -0
- flopscope/_accumulation/_cache.py +146 -0
- flopscope/_accumulation/_components.py +153 -0
- flopscope/_accumulation/_cost.py +1414 -0
- flopscope/_accumulation/_cost_descriptions.py +63 -0
- flopscope/_accumulation/_detection.py +318 -0
- flopscope/_accumulation/_ladder.py +191 -0
- flopscope/_accumulation/_output_orbit.py +104 -0
- flopscope/_accumulation/_partition.py +290 -0
- flopscope/_accumulation/_path_info.py +211 -0
- flopscope/_accumulation/_public.py +169 -0
- flopscope/_accumulation/_reduction.py +310 -0
- flopscope/_accumulation/_regimes.py +303 -0
- flopscope/_accumulation/_shape.py +33 -0
- flopscope/_accumulation/_wreath.py +209 -0
- flopscope/_budget.py +1027 -0
- flopscope/_config.py +118 -0
- flopscope/_counting_ops.py +451 -0
- flopscope/_display.py +478 -0
- flopscope/_docstrings.py +59 -0
- flopscope/_dtypes.py +20 -0
- flopscope/_einsum.py +717 -0
- flopscope/_errstate.py +25 -0
- flopscope/_flops.py +282 -0
- flopscope/_free_ops.py +2654 -0
- flopscope/_ndarray.py +1126 -0
- flopscope/_opt_einsum/LICENSE +21 -0
- flopscope/_opt_einsum/NOTICE +59 -0
- flopscope/_opt_einsum/__init__.py +209 -0
- flopscope/_opt_einsum/_contract.py +1478 -0
- flopscope/_opt_einsum/_helpers.py +164 -0
- flopscope/_opt_einsum/_hsluv.py +273 -0
- flopscope/_opt_einsum/_path_random.py +462 -0
- flopscope/_opt_einsum/_paths.py +1653 -0
- flopscope/_opt_einsum/_subgraph_symmetry.py +544 -0
- flopscope/_opt_einsum/_symmetry.py +140 -0
- flopscope/_opt_einsum/_typing.py +37 -0
- flopscope/_perm_group.py +717 -0
- flopscope/_pointwise.py +2522 -0
- flopscope/_polynomial.py +278 -0
- flopscope/_registry.py +3216 -0
- flopscope/_sorting_ops.py +571 -0
- flopscope/_symmetric.py +812 -0
- flopscope/_symmetry_transport.py +510 -0
- flopscope/_symmetry_utils.py +669 -0
- flopscope/_type_info.py +12 -0
- flopscope/_unwrap.py +70 -0
- flopscope/_validation.py +83 -0
- flopscope/_version_check.py +46 -0
- flopscope/_weights.py +195 -0
- flopscope/_window.py +177 -0
- flopscope/accounting.py +565 -0
- flopscope/data/default_weights.json +462 -0
- flopscope/data/weights.csv +509 -0
- flopscope/errors.py +197 -0
- flopscope/numpy/__init__.py +878 -0
- flopscope/numpy/fft/__init__.py +55 -0
- flopscope/numpy/fft/_free.py +51 -0
- flopscope/numpy/fft/_transforms.py +695 -0
- flopscope/numpy/linalg/__init__.py +105 -0
- flopscope/numpy/linalg/_aliases.py +126 -0
- flopscope/numpy/linalg/_compound.py +161 -0
- flopscope/numpy/linalg/_decompositions.py +353 -0
- flopscope/numpy/linalg/_properties.py +533 -0
- flopscope/numpy/linalg/_solvers.py +444 -0
- flopscope/numpy/linalg/_svd.py +122 -0
- flopscope/numpy/random/__init__.py +684 -0
- flopscope/numpy/random/_cost_formulas.py +115 -0
- flopscope/numpy/random/_counted_classes.py +241 -0
- flopscope/numpy/testing/__init__.py +13 -0
- flopscope/numpy/typing/__init__.py +30 -0
- flopscope/py.typed +0 -0
- flopscope/stats/__init__.py +84 -0
- flopscope/stats/_base.py +77 -0
- flopscope/stats/_cauchy.py +146 -0
- flopscope/stats/_erf.py +190 -0
- flopscope/stats/_expon.py +146 -0
- flopscope/stats/_laplace.py +150 -0
- flopscope/stats/_logistic.py +148 -0
- flopscope/stats/_lognorm.py +160 -0
- flopscope/stats/_ndtri.py +133 -0
- flopscope/stats/_norm.py +149 -0
- flopscope/stats/_truncnorm.py +186 -0
- flopscope/stats/_uniform.py +141 -0
- flopscope-0.2.0.dist-info/METADATA +23 -0
- flopscope-0.2.0.dist-info/RECORD +115 -0
- flopscope-0.2.0.dist-info/WHEEL +4 -0
flopscope/_symmetric.py
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
"""Symmetric tensor support: SymmetricTensor, as_symmetric, and helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from flopscope._ndarray import FlopscopeArray, _asplainflopscope
|
|
8
|
+
from flopscope._perm_group import SymmetryGroup
|
|
9
|
+
from flopscope._symmetry_utils import (
|
|
10
|
+
broadcast_group,
|
|
11
|
+
inserted_axes_symmetry,
|
|
12
|
+
intersect_groups,
|
|
13
|
+
normalize_symmetry_input,
|
|
14
|
+
reduce_group,
|
|
15
|
+
remap_group_axes,
|
|
16
|
+
restrict_group_to_axes,
|
|
17
|
+
validate_symmetry_group,
|
|
18
|
+
)
|
|
19
|
+
from flopscope.errors import SymmetryError
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Validation
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_symmetry(
|
|
27
|
+
data: np.ndarray,
|
|
28
|
+
axis_groups: list[tuple[int, ...]],
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Validate that *data* has the claimed symmetry.
|
|
31
|
+
|
|
32
|
+
For each group, checks that all dims have equal sizes and that all
|
|
33
|
+
pairwise transpositions are satisfied within tolerance.
|
|
34
|
+
|
|
35
|
+
Raises
|
|
36
|
+
------
|
|
37
|
+
SymmetryError
|
|
38
|
+
If the data is not symmetric along the claimed axes.
|
|
39
|
+
"""
|
|
40
|
+
for group in axis_groups:
|
|
41
|
+
if len(group) < 2:
|
|
42
|
+
continue
|
|
43
|
+
# Check equal sizes.
|
|
44
|
+
sizes = [data.shape[d] for d in group]
|
|
45
|
+
if len(set(sizes)) != 1:
|
|
46
|
+
raise SymmetryError(axes=group, max_deviation=float("inf"))
|
|
47
|
+
# Check pairwise transpositions.
|
|
48
|
+
for i in range(len(group)):
|
|
49
|
+
for j in range(i + 1, len(group)):
|
|
50
|
+
axes = list(range(data.ndim))
|
|
51
|
+
axes[group[i]], axes[group[j]] = axes[group[j]], axes[group[i]]
|
|
52
|
+
transposed = data.transpose(axes)
|
|
53
|
+
if not np.allclose(data, transposed, atol=1e-6, rtol=1e-5):
|
|
54
|
+
max_dev = float(np.max(np.abs(data - transposed)))
|
|
55
|
+
raise SymmetryError(axes=group, max_deviation=max_dev)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def symmetrize(
|
|
59
|
+
data: np.ndarray,
|
|
60
|
+
*,
|
|
61
|
+
symmetry,
|
|
62
|
+
) -> SymmetricTensor:
|
|
63
|
+
"""Project an array onto the invariant subspace of a permutation group.
|
|
64
|
+
|
|
65
|
+
This applies Reynolds symmetrization:
|
|
66
|
+
|
|
67
|
+
``R_G(T) = (1 / |G|) * sum_{g in G} g · T``
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
data : array_like
|
|
72
|
+
Input array to project.
|
|
73
|
+
symmetry : SymmetryGroup
|
|
74
|
+
Symmetry group to average over. If ``symmetry.axes`` is ``None``, axes are
|
|
75
|
+
interpreted as ``tuple(range(symmetry.degree))``.
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
SymmetricTensor
|
|
80
|
+
The projected tensor, validated and wrapped as a :class:`SymmetricTensor`.
|
|
81
|
+
|
|
82
|
+
Raises
|
|
83
|
+
------
|
|
84
|
+
SymmetryError
|
|
85
|
+
If ``data`` has incompatible dimensions for ``group`` axes or if the
|
|
86
|
+
projected result cannot be validated as symmetric for ``group``.
|
|
87
|
+
|
|
88
|
+
Notes
|
|
89
|
+
-----
|
|
90
|
+
``symmetrize`` performs exact Reynolds averaging internally and then delegates
|
|
91
|
+
to :func:`as_symmetric` so the result participates in downstream symmetry
|
|
92
|
+
tracking and validation in the usual way.
|
|
93
|
+
|
|
94
|
+
Estimated FLOP cost is approximately:
|
|
95
|
+
|
|
96
|
+
``|G| * n_elem + n_elem`` for projection, plus validation from
|
|
97
|
+
``as_symmetric``:
|
|
98
|
+
|
|
99
|
+
- ``|G|`` transposed add passes over ``n_elem`` elements
|
|
100
|
+
- one final scaling pass
|
|
101
|
+
- symmetry checks inside validation
|
|
102
|
+
|
|
103
|
+
where ``|G|`` is the group order and ``n_elem = data.size``.
|
|
104
|
+
|
|
105
|
+
The canonical pattern for generating random data with symmetry is:
|
|
106
|
+
|
|
107
|
+
``fnp.random.symmetric(shape, symmetry_group, distribution=...)``.
|
|
108
|
+
|
|
109
|
+
Examples
|
|
110
|
+
--------
|
|
111
|
+
>>> import flopscope as flops
|
|
112
|
+
>>> import flopscope.numpy as fnp
|
|
113
|
+
>>> data = fnp.random.randn(4, 4)
|
|
114
|
+
>>> S = flops.symmetrize(data, flops.SymmetryGroup.symmetric(axes=(0, 1)))
|
|
115
|
+
>>> S.is_symmetric((0, 1))
|
|
116
|
+
True
|
|
117
|
+
"""
|
|
118
|
+
array = np.asarray(data)
|
|
119
|
+
group = _resolve_symmetry_argument(
|
|
120
|
+
array,
|
|
121
|
+
symmetry=symmetry,
|
|
122
|
+
)
|
|
123
|
+
assert group is not None # required=True raises if symmetry is None
|
|
124
|
+
validate_symmetry_group(group, ndim=array.ndim, shape=array.shape)
|
|
125
|
+
group_axes = group.axes if group.axes is not None else tuple(range(group.degree))
|
|
126
|
+
symmetrized = np.zeros_like(array, dtype=np.result_type(array, np.float64))
|
|
127
|
+
|
|
128
|
+
for g in group.elements():
|
|
129
|
+
perm = list(range(array.ndim))
|
|
130
|
+
for local_idx, tensor_axis in enumerate(group_axes):
|
|
131
|
+
perm[tensor_axis] = group_axes[g.array_form[local_idx]]
|
|
132
|
+
symmetrized = symmetrized + np.transpose(array, perm)
|
|
133
|
+
|
|
134
|
+
return as_symmetric(symmetrized / group.order(), symmetry=group)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def validate_symmetry_groups(data: np.ndarray, groups: list) -> None:
|
|
138
|
+
"""Validate that *data* is symmetric under the given SymmetryGroups.
|
|
139
|
+
|
|
140
|
+
Raises
|
|
141
|
+
------
|
|
142
|
+
ValueError
|
|
143
|
+
If a group has no axes set.
|
|
144
|
+
SymmetryError
|
|
145
|
+
If the data is not symmetric under the claimed group.
|
|
146
|
+
"""
|
|
147
|
+
for group in groups:
|
|
148
|
+
axes = group.axes
|
|
149
|
+
if axes is None:
|
|
150
|
+
axes = tuple(range(group.degree))
|
|
151
|
+
group._axes = axes
|
|
152
|
+
validate_symmetry_group(group, ndim=data.ndim, shape=data.shape)
|
|
153
|
+
for orbit in group.orbits():
|
|
154
|
+
sizes = {data.shape[axes[i]] for i in orbit}
|
|
155
|
+
if len(sizes) != 1:
|
|
156
|
+
raise SymmetryError(
|
|
157
|
+
axes=tuple(axes[i] for i in orbit), max_deviation=float("inf")
|
|
158
|
+
)
|
|
159
|
+
for gen in group.generators:
|
|
160
|
+
if gen.is_identity:
|
|
161
|
+
continue
|
|
162
|
+
perm = list(range(data.ndim))
|
|
163
|
+
for i in range(group.degree):
|
|
164
|
+
perm[axes[i]] = axes[gen._array_form[i]]
|
|
165
|
+
transposed = data.transpose(perm)
|
|
166
|
+
if not np.allclose(data, transposed, atol=1e-6, rtol=1e-5):
|
|
167
|
+
max_dev = float(np.max(np.abs(data - transposed)))
|
|
168
|
+
raise SymmetryError(axes=tuple(axes), max_deviation=max_dev)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _resolve_symmetry_argument(
|
|
172
|
+
data: np.ndarray,
|
|
173
|
+
*,
|
|
174
|
+
symmetry,
|
|
175
|
+
required: bool = True,
|
|
176
|
+
):
|
|
177
|
+
if symmetry is None:
|
|
178
|
+
if required:
|
|
179
|
+
raise ValueError("symmetry must be provided")
|
|
180
|
+
return None
|
|
181
|
+
return normalize_symmetry_input(symmetry, ndim=np.asarray(data).ndim)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_symmetric(
|
|
185
|
+
data: np.ndarray,
|
|
186
|
+
*,
|
|
187
|
+
symmetry,
|
|
188
|
+
atol: float = 1e-6,
|
|
189
|
+
rtol: float = 1e-5,
|
|
190
|
+
) -> bool:
|
|
191
|
+
"""Check whether *data* is invariant under the given symmetry.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
data : numpy.ndarray
|
|
196
|
+
The array to test.
|
|
197
|
+
symmetry : SymmetryGroup or array-like specification
|
|
198
|
+
Symmetry to verify, normalized via :func:`normalize_symmetry_input`.
|
|
199
|
+
atol : float, optional
|
|
200
|
+
Absolute tolerance used by :func:`numpy.allclose`. Default ``1e-6``.
|
|
201
|
+
rtol : float, optional
|
|
202
|
+
Relative tolerance used by :func:`numpy.allclose`. Default ``1e-5``.
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
bool
|
|
207
|
+
``True`` if *data* is invariant under every group element, otherwise
|
|
208
|
+
``False``.
|
|
209
|
+
|
|
210
|
+
Examples
|
|
211
|
+
--------
|
|
212
|
+
>>> import flopscope as flops
|
|
213
|
+
>>> import flopscope.numpy as fnp
|
|
214
|
+
>>> matrix = fnp.array([[1.0, 2.0], [2.0, 3.0]])
|
|
215
|
+
>>> flops.is_symmetric(
|
|
216
|
+
... matrix, symmetry=flops.SymmetryGroup.symmetric(axes=(0, 1))
|
|
217
|
+
... )
|
|
218
|
+
True
|
|
219
|
+
"""
|
|
220
|
+
group = _resolve_symmetry_argument(
|
|
221
|
+
data,
|
|
222
|
+
symmetry=symmetry,
|
|
223
|
+
required=False,
|
|
224
|
+
)
|
|
225
|
+
if group is None:
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
array = np.asarray(data)
|
|
229
|
+
group_axes = group.axes if group.axes is not None else tuple(range(group.degree))
|
|
230
|
+
validate_symmetry_group(group, ndim=array.ndim, shape=array.shape)
|
|
231
|
+
|
|
232
|
+
for elem in group.elements():
|
|
233
|
+
axes = list(range(array.ndim))
|
|
234
|
+
for src_local, dst_local in enumerate(elem.array_form):
|
|
235
|
+
axes[group_axes[src_local]] = group_axes[dst_local]
|
|
236
|
+
if not np.allclose(array, array.transpose(axes), atol=atol, rtol=rtol):
|
|
237
|
+
return False
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Symmetry-loss warning helper
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
from flopscope.errors import ( # noqa: E402
|
|
246
|
+
_warn_symmetry_loss,
|
|
247
|
+
) # re-exported for back-compat
|
|
248
|
+
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Symmetry propagation helpers
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def propagate_symmetry_slice(
|
|
255
|
+
groups: list[SymmetryGroup],
|
|
256
|
+
shape: tuple[int, ...],
|
|
257
|
+
key,
|
|
258
|
+
) -> list[SymmetryGroup] | None:
|
|
259
|
+
"""Compute new symmetry groups after ``__getitem__(key)``.
|
|
260
|
+
|
|
261
|
+
Parameters
|
|
262
|
+
----------
|
|
263
|
+
groups : list of SymmetryGroup
|
|
264
|
+
Each group has ``axes`` indicating which tensor dimensions it acts on.
|
|
265
|
+
shape : tuple of int
|
|
266
|
+
Original tensor shape.
|
|
267
|
+
key : indexing key
|
|
268
|
+
The slicing/indexing key.
|
|
269
|
+
|
|
270
|
+
Returns
|
|
271
|
+
-------
|
|
272
|
+
list of SymmetryGroup or None
|
|
273
|
+
Surviving groups, or ``None`` if no symmetry survives.
|
|
274
|
+
"""
|
|
275
|
+
ndim = len(shape)
|
|
276
|
+
|
|
277
|
+
if not isinstance(key, tuple):
|
|
278
|
+
key = (key,)
|
|
279
|
+
|
|
280
|
+
for k in key:
|
|
281
|
+
# Bool indices are boolean masks in numpy (add a size-1 batch axis for
|
|
282
|
+
# True / size-0 for False), not integer scalars. `isinstance(True, int)`
|
|
283
|
+
# is also True in Python, so we must check bool BEFORE the int branch
|
|
284
|
+
# below would silently misclassify it as an integer scalar index.
|
|
285
|
+
if isinstance(k, (np.ndarray, list, bool, np.bool_)):
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
# Expand Ellipsis.
|
|
289
|
+
expanded: list = []
|
|
290
|
+
ellipsis_seen = False
|
|
291
|
+
for k in key:
|
|
292
|
+
if k is Ellipsis:
|
|
293
|
+
if ellipsis_seen:
|
|
294
|
+
raise IndexError("only one Ellipsis allowed")
|
|
295
|
+
ellipsis_seen = True
|
|
296
|
+
n_newaxis_in_key = sum(1 for kk in key if kk is None)
|
|
297
|
+
n_explicit = len(key) - 1 - n_newaxis_in_key
|
|
298
|
+
n_fill = ndim - n_explicit
|
|
299
|
+
expanded.extend([slice(None)] * n_fill)
|
|
300
|
+
else:
|
|
301
|
+
expanded.append(k)
|
|
302
|
+
if not ellipsis_seen:
|
|
303
|
+
n_newaxis = sum(1 for k in expanded if k is None)
|
|
304
|
+
while len(expanded) - n_newaxis < ndim:
|
|
305
|
+
expanded.append(slice(None))
|
|
306
|
+
key_expanded = expanded
|
|
307
|
+
|
|
308
|
+
# Classify each original dim.
|
|
309
|
+
old_dim_idx = 0
|
|
310
|
+
dim_actions: dict[int, str | tuple] = {}
|
|
311
|
+
|
|
312
|
+
for k in key_expanded:
|
|
313
|
+
if k is None:
|
|
314
|
+
continue
|
|
315
|
+
if old_dim_idx >= ndim:
|
|
316
|
+
break
|
|
317
|
+
if isinstance(k, (int, np.integer)):
|
|
318
|
+
dim_actions[old_dim_idx] = "removed"
|
|
319
|
+
old_dim_idx += 1
|
|
320
|
+
elif isinstance(k, slice):
|
|
321
|
+
start, stop, step = k.indices(shape[old_dim_idx])
|
|
322
|
+
if start == 0 and stop == shape[old_dim_idx] and step == 1:
|
|
323
|
+
dim_actions[old_dim_idx] = "untouched"
|
|
324
|
+
else:
|
|
325
|
+
new_size = max(
|
|
326
|
+
0, (stop - start + (step - (1 if step > 0 else -1))) // step
|
|
327
|
+
)
|
|
328
|
+
dim_actions[old_dim_idx] = ("resized", new_size)
|
|
329
|
+
old_dim_idx += 1
|
|
330
|
+
else:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
while old_dim_idx < ndim:
|
|
334
|
+
dim_actions[old_dim_idx] = "untouched"
|
|
335
|
+
old_dim_idx += 1
|
|
336
|
+
|
|
337
|
+
# Build old→new dim mapping.
|
|
338
|
+
removed_dims = {d for d, a in dim_actions.items() if a == "removed"}
|
|
339
|
+
old_to_new: dict[int, int | None] = {}
|
|
340
|
+
newaxis_positions: list[int] = []
|
|
341
|
+
orig_idx = 0
|
|
342
|
+
for k in key_expanded:
|
|
343
|
+
if k is None:
|
|
344
|
+
newaxis_positions.append(orig_idx)
|
|
345
|
+
else:
|
|
346
|
+
orig_idx += 1
|
|
347
|
+
|
|
348
|
+
new_idx = 0
|
|
349
|
+
for d in range(ndim):
|
|
350
|
+
while newaxis_positions and newaxis_positions[0] <= d:
|
|
351
|
+
newaxis_positions.pop(0)
|
|
352
|
+
new_idx += 1
|
|
353
|
+
if d in removed_dims:
|
|
354
|
+
old_to_new[d] = None
|
|
355
|
+
else:
|
|
356
|
+
old_to_new[d] = new_idx
|
|
357
|
+
new_idx += 1
|
|
358
|
+
|
|
359
|
+
# Process each group.
|
|
360
|
+
new_groups: list[SymmetryGroup] = []
|
|
361
|
+
for group in groups:
|
|
362
|
+
axes = group.axes
|
|
363
|
+
if axes is None:
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Map tensor axes to group-local indices.
|
|
367
|
+
local_removed: set[int] = set()
|
|
368
|
+
local_kept: list[int] = []
|
|
369
|
+
for local_idx, tensor_dim in enumerate(axes):
|
|
370
|
+
action = dim_actions.get(tensor_dim, "untouched")
|
|
371
|
+
if action == "removed":
|
|
372
|
+
local_removed.add(local_idx)
|
|
373
|
+
else:
|
|
374
|
+
local_kept.append(local_idx)
|
|
375
|
+
|
|
376
|
+
if not local_kept:
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
if any(
|
|
380
|
+
dim_actions.get(axes[local_idx], "untouched") != "untouched"
|
|
381
|
+
for local_idx in local_kept
|
|
382
|
+
):
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
# Pointwise stabilizer: each removed axis must map to itself.
|
|
386
|
+
# (Setwise would only be valid when all removed axes share the
|
|
387
|
+
# same slice value, which we can't determine in general.)
|
|
388
|
+
stab = group.pointwise_stabilizer(local_removed)
|
|
389
|
+
|
|
390
|
+
# Restrict to kept local indices.
|
|
391
|
+
kept_tuple = tuple(local_kept)
|
|
392
|
+
if len(kept_tuple) < 2:
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
restricted = restrict_group_to_axes(stab, tuple(axes[k] for k in kept_tuple))
|
|
396
|
+
if restricted is None:
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
final = remap_group_axes(
|
|
400
|
+
restricted,
|
|
401
|
+
{axes[k]: old_to_new[axes[k]] for k in kept_tuple}, # type: ignore[arg-type]
|
|
402
|
+
)
|
|
403
|
+
if final is None:
|
|
404
|
+
continue
|
|
405
|
+
new_groups.append(final)
|
|
406
|
+
|
|
407
|
+
# Build the inserted-axis group from freshly-inserted None positions.
|
|
408
|
+
inserted_output_positions: list[int] = []
|
|
409
|
+
out_idx = 0
|
|
410
|
+
for k in key_expanded:
|
|
411
|
+
if k is None:
|
|
412
|
+
inserted_output_positions.append(out_idx)
|
|
413
|
+
out_idx += 1
|
|
414
|
+
elif isinstance(k, (int, np.integer)):
|
|
415
|
+
pass # axis removed; no output slot
|
|
416
|
+
else:
|
|
417
|
+
out_idx += 1
|
|
418
|
+
|
|
419
|
+
inserted_group = inserted_axes_symmetry(inserted_output_positions)
|
|
420
|
+
if inserted_group is not None:
|
|
421
|
+
new_groups.append(inserted_group)
|
|
422
|
+
|
|
423
|
+
return new_groups if new_groups else None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def propagate_symmetry_reduce(
|
|
427
|
+
groups: list[SymmetryGroup],
|
|
428
|
+
ndim: int,
|
|
429
|
+
axis: int | tuple[int, ...] | None,
|
|
430
|
+
keepdims: bool = False,
|
|
431
|
+
) -> list[SymmetryGroup] | None:
|
|
432
|
+
"""Compute new symmetry groups after a reduction.
|
|
433
|
+
|
|
434
|
+
Parameters
|
|
435
|
+
----------
|
|
436
|
+
groups : list of SymmetryGroup
|
|
437
|
+
Each group has ``axes`` indicating which tensor dimensions it acts on.
|
|
438
|
+
ndim : int
|
|
439
|
+
Original tensor rank.
|
|
440
|
+
axis : int, tuple of int, or None
|
|
441
|
+
Axes being reduced.
|
|
442
|
+
keepdims : bool
|
|
443
|
+
Whether reduced dims are kept at size 1.
|
|
444
|
+
|
|
445
|
+
Returns
|
|
446
|
+
-------
|
|
447
|
+
list of SymmetryGroup or None
|
|
448
|
+
Surviving groups, or ``None`` if no symmetry survives.
|
|
449
|
+
"""
|
|
450
|
+
new_groups: list[SymmetryGroup] = []
|
|
451
|
+
for group in groups:
|
|
452
|
+
reduced = reduce_group(group, ndim=ndim, axis=axis, keepdims=keepdims)
|
|
453
|
+
if reduced is not None:
|
|
454
|
+
new_groups.append(reduced)
|
|
455
|
+
|
|
456
|
+
return new_groups if new_groups else None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def intersect_symmetry(
|
|
460
|
+
groups_a: list[SymmetryGroup] | None,
|
|
461
|
+
groups_b: list[SymmetryGroup] | None,
|
|
462
|
+
shape_a: tuple[int, ...],
|
|
463
|
+
shape_b: tuple[int, ...],
|
|
464
|
+
output_shape: tuple[int, ...],
|
|
465
|
+
) -> list[SymmetryGroup] | None:
|
|
466
|
+
"""Intersect symmetry groups for binary ops, accounting for broadcasting.
|
|
467
|
+
|
|
468
|
+
For groups acting on the same output axes, computes the element-set
|
|
469
|
+
intersection. Broadcast-stretched dimensions (size 1 → larger) are
|
|
470
|
+
removed from groups before intersecting.
|
|
471
|
+
|
|
472
|
+
Parameters
|
|
473
|
+
----------
|
|
474
|
+
groups_a, groups_b : list of SymmetryGroup or None
|
|
475
|
+
Symmetry groups for each operand.
|
|
476
|
+
shape_a, shape_b : tuple of int
|
|
477
|
+
Input shapes (before broadcasting).
|
|
478
|
+
output_shape : tuple of int
|
|
479
|
+
Broadcast output shape.
|
|
480
|
+
|
|
481
|
+
Returns
|
|
482
|
+
-------
|
|
483
|
+
list of SymmetryGroup or None
|
|
484
|
+
Groups present in both operands, or *None* if no shared symmetry.
|
|
485
|
+
"""
|
|
486
|
+
if groups_a is None or groups_b is None:
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
ndim_out = len(output_shape)
|
|
490
|
+
|
|
491
|
+
aligned_a = [
|
|
492
|
+
aligned
|
|
493
|
+
for group in groups_a
|
|
494
|
+
if (
|
|
495
|
+
aligned := broadcast_group(
|
|
496
|
+
group, input_shape=shape_a, output_shape=output_shape
|
|
497
|
+
)
|
|
498
|
+
)
|
|
499
|
+
is not None
|
|
500
|
+
]
|
|
501
|
+
aligned_b = [
|
|
502
|
+
aligned
|
|
503
|
+
for group in groups_b
|
|
504
|
+
if (
|
|
505
|
+
aligned := broadcast_group(
|
|
506
|
+
group, input_shape=shape_b, output_shape=output_shape
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
is not None
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
# Intersect: for groups acting on the same output axes, compute element intersection.
|
|
513
|
+
b_by_axes: dict[tuple[int, ...], SymmetryGroup] = {}
|
|
514
|
+
for g in aligned_b:
|
|
515
|
+
if g.axes is not None:
|
|
516
|
+
b_by_axes[g.axes] = g
|
|
517
|
+
|
|
518
|
+
intersection: list[SymmetryGroup] = []
|
|
519
|
+
for ga in aligned_a:
|
|
520
|
+
if ga.axes is None:
|
|
521
|
+
continue
|
|
522
|
+
gb = b_by_axes.get(ga.axes)
|
|
523
|
+
if gb is None:
|
|
524
|
+
continue
|
|
525
|
+
common = intersect_groups(ga, gb, ndim=ndim_out)
|
|
526
|
+
if common is not None:
|
|
527
|
+
intersection.append(common)
|
|
528
|
+
|
|
529
|
+
return intersection if intersection else None
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
# SymmetricTensor (np.ndarray subclass)
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _merge_symmetry_groups(groups) -> SymmetryGroup | None:
|
|
538
|
+
groups = [group for group in groups if group is not None]
|
|
539
|
+
if not groups:
|
|
540
|
+
return None
|
|
541
|
+
if len(groups) == 1:
|
|
542
|
+
return groups[0]
|
|
543
|
+
return SymmetryGroup.direct_product(*groups)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _wrap_tensor_result(data: np.ndarray, symmetry: SymmetryGroup | None):
|
|
547
|
+
if symmetry is None:
|
|
548
|
+
return _asplainflopscope(data)
|
|
549
|
+
return SymmetricTensor(data, symmetry=symmetry)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class SymmetricTensor(FlopscopeArray):
|
|
553
|
+
"""An ndarray that carries symmetry metadata.
|
|
554
|
+
|
|
555
|
+
Do not instantiate directly; use :func:`as_symmetric`.
|
|
556
|
+
"""
|
|
557
|
+
|
|
558
|
+
__slots__ = ("_symmetry", "_symmetry_inferred")
|
|
559
|
+
|
|
560
|
+
def __new__(
|
|
561
|
+
cls,
|
|
562
|
+
input_array: np.ndarray,
|
|
563
|
+
*,
|
|
564
|
+
symmetry: SymmetryGroup,
|
|
565
|
+
) -> SymmetricTensor:
|
|
566
|
+
obj = np.asarray(input_array).view(cls)
|
|
567
|
+
obj._symmetry = symmetry
|
|
568
|
+
obj._symmetry_inferred = False
|
|
569
|
+
return obj
|
|
570
|
+
|
|
571
|
+
def __array_finalize__(self, obj: object) -> None:
|
|
572
|
+
self._symmetry = None
|
|
573
|
+
self._symmetry_inferred = False
|
|
574
|
+
|
|
575
|
+
def __array_wrap__(self, out_arr, context=None, return_scalar=False):
|
|
576
|
+
result = super().__array_wrap__(out_arr, context, return_scalar)
|
|
577
|
+
if return_scalar:
|
|
578
|
+
return result
|
|
579
|
+
if isinstance(result, SymmetricTensor) and result._symmetry is None:
|
|
580
|
+
return _asplainflopscope(np.asarray(result))
|
|
581
|
+
return result
|
|
582
|
+
|
|
583
|
+
# -- public API --
|
|
584
|
+
|
|
585
|
+
@property
|
|
586
|
+
def symmetry(self) -> SymmetryGroup:
|
|
587
|
+
"""Exact symmetry group carried by this tensor."""
|
|
588
|
+
return self._symmetry # type: ignore[return-value]
|
|
589
|
+
|
|
590
|
+
def is_symmetric(
|
|
591
|
+
self,
|
|
592
|
+
*,
|
|
593
|
+
symmetry=None,
|
|
594
|
+
atol: float = 1e-6,
|
|
595
|
+
rtol: float = 1e-5,
|
|
596
|
+
) -> bool:
|
|
597
|
+
"""Check whether the data satisfies the given (or carried) symmetry."""
|
|
598
|
+
group = (
|
|
599
|
+
self._symmetry
|
|
600
|
+
if symmetry is None
|
|
601
|
+
else _resolve_symmetry_argument(
|
|
602
|
+
self,
|
|
603
|
+
symmetry=symmetry,
|
|
604
|
+
required=False,
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
if group is None:
|
|
608
|
+
return False
|
|
609
|
+
return is_symmetric(np.asarray(self), symmetry=group, atol=atol, rtol=rtol)
|
|
610
|
+
|
|
611
|
+
# -- slicing with symmetry propagation --
|
|
612
|
+
|
|
613
|
+
def __getitem__(self, key): # type: ignore[override]
|
|
614
|
+
"""Index with symmetry propagation.
|
|
615
|
+
|
|
616
|
+
Computes the pointwise-stabilizer subgroup for axes removed by
|
|
617
|
+
integer indexing, then restricts surviving groups to the output
|
|
618
|
+
axes. Returns a plain ``ndarray`` when no symmetry survives.
|
|
619
|
+
Emits :class:`~flopscope.errors.SymmetryLossWarning` on partial or
|
|
620
|
+
total symmetry loss.
|
|
621
|
+
"""
|
|
622
|
+
result = super().__getitem__(key)
|
|
623
|
+
if not isinstance(result, np.ndarray) or result.ndim == 0:
|
|
624
|
+
return result if not isinstance(result, np.ndarray) else np.asarray(result)
|
|
625
|
+
|
|
626
|
+
if self._symmetry is None:
|
|
627
|
+
# Even with no input symmetry, multiple inserted None axes form a
|
|
628
|
+
# free symmetric group on those axes. Run the propagator with an
|
|
629
|
+
# empty groups list so the inserted-axis logic still fires.
|
|
630
|
+
new_groups = propagate_symmetry_slice([], self.shape, key)
|
|
631
|
+
if new_groups:
|
|
632
|
+
return _wrap_tensor_result(
|
|
633
|
+
np.asarray(result), _merge_symmetry_groups(new_groups)
|
|
634
|
+
)
|
|
635
|
+
return _asplainflopscope(np.asarray(result))
|
|
636
|
+
|
|
637
|
+
new_groups = propagate_symmetry_slice([self._symmetry], self.shape, key)
|
|
638
|
+
new_symmetry = _merge_symmetry_groups(new_groups or [])
|
|
639
|
+
if new_groups is not None:
|
|
640
|
+
# Fire only on real structural reduction: the new group's order is
|
|
641
|
+
# strictly less than the original's. This silences false alarms on
|
|
642
|
+
# operations that gain symmetry (e.g. `a[None, :, None, :]`
|
|
643
|
+
# produces a richer Young group) or merely relabel axes without
|
|
644
|
+
# losing structure (e.g. `a[None, :, :]` shifts axes, preserves
|
|
645
|
+
# order). It under-fires on the rare case where original sym is
|
|
646
|
+
# broken and a same-order new group is gained on different axes;
|
|
647
|
+
# the gained group is still attached to the result so a careful
|
|
648
|
+
# user can inspect `.symmetry` directly.
|
|
649
|
+
if (
|
|
650
|
+
new_symmetry is not None
|
|
651
|
+
and self._symmetry.axes is not None
|
|
652
|
+
and new_symmetry.order() < self._symmetry.order()
|
|
653
|
+
):
|
|
654
|
+
_warn_symmetry_loss(
|
|
655
|
+
[self._symmetry.axes],
|
|
656
|
+
"slicing reduced symmetric group structure",
|
|
657
|
+
)
|
|
658
|
+
return _wrap_tensor_result(np.asarray(result), new_symmetry)
|
|
659
|
+
|
|
660
|
+
if self._symmetry.axes is not None:
|
|
661
|
+
_warn_symmetry_loss(
|
|
662
|
+
[self._symmetry.axes],
|
|
663
|
+
"slicing removed all symmetric dim groups",
|
|
664
|
+
)
|
|
665
|
+
return _asplainflopscope(np.asarray(result))
|
|
666
|
+
|
|
667
|
+
# -- copy preserves metadata --
|
|
668
|
+
|
|
669
|
+
def copy(self, order: str = "C") -> SymmetricTensor: # type: ignore[override]
|
|
670
|
+
out = super().copy(order=order).view(type(self)) # type: ignore[arg-type]
|
|
671
|
+
out._symmetry = self._symmetry
|
|
672
|
+
return out
|
|
673
|
+
|
|
674
|
+
def reshape(self, *shape, **kwargs): # type: ignore[override]
|
|
675
|
+
from flopscope._free_ops import reshape as _reshape
|
|
676
|
+
|
|
677
|
+
return _reshape(self, *shape, **kwargs)
|
|
678
|
+
|
|
679
|
+
def ravel(self, order: str = "C"): # type: ignore[override]
|
|
680
|
+
from flopscope._free_ops import ravel as _ravel
|
|
681
|
+
|
|
682
|
+
return _ravel(self, order=order)
|
|
683
|
+
|
|
684
|
+
def flatten(self, order: str = "C"): # type: ignore[override]
|
|
685
|
+
from flopscope._free_ops import ravel as _ravel
|
|
686
|
+
from flopscope._symmetry_transport import transport_ravel
|
|
687
|
+
from flopscope.errors import _warn_symmetry_loss
|
|
688
|
+
|
|
689
|
+
in_group = self._symmetry
|
|
690
|
+
if in_group is not None:
|
|
691
|
+
out_group = transport_ravel(in_group, input_shape=np.asarray(self).shape)
|
|
692
|
+
if out_group is None:
|
|
693
|
+
_warn_symmetry_loss(
|
|
694
|
+
lost_dims=[in_group.axes or tuple(range(in_group.degree))],
|
|
695
|
+
reason="flatten collapses to a single axis; block cannot fit",
|
|
696
|
+
)
|
|
697
|
+
# Pass the raw ndarray view so _ravel does not emit a second warning.
|
|
698
|
+
out = _ravel(np.asarray(self), order=order)
|
|
699
|
+
return np.array(out, copy=True)
|
|
700
|
+
|
|
701
|
+
def squeeze(self, axis=None): # type: ignore[override]
|
|
702
|
+
from flopscope._free_ops import squeeze as _squeeze
|
|
703
|
+
|
|
704
|
+
return _squeeze(self, axis=axis) # type: ignore[arg-type]
|
|
705
|
+
|
|
706
|
+
def astype( # type: ignore[override]
|
|
707
|
+
self,
|
|
708
|
+
dtype,
|
|
709
|
+
order: str = "K",
|
|
710
|
+
casting: str = "unsafe",
|
|
711
|
+
subok: bool = False,
|
|
712
|
+
copy: bool = True,
|
|
713
|
+
):
|
|
714
|
+
return _asplainflopscope(
|
|
715
|
+
np.asarray(self).astype(
|
|
716
|
+
dtype,
|
|
717
|
+
order=order, # type: ignore[arg-type]
|
|
718
|
+
casting=casting, # type: ignore[arg-type]
|
|
719
|
+
subok=False,
|
|
720
|
+
copy=copy,
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
def transpose(self, *axes): # type: ignore[override]
|
|
725
|
+
if not axes or axes == (None,):
|
|
726
|
+
order = tuple(reversed(range(self.ndim)))
|
|
727
|
+
elif len(axes) == 1 and isinstance(axes[0], (tuple, list)):
|
|
728
|
+
order = tuple(axes[0])
|
|
729
|
+
else:
|
|
730
|
+
order = tuple(axes)
|
|
731
|
+
result = np.transpose(np.asarray(self), axes=order)
|
|
732
|
+
mapping = {old: new for new, old in enumerate(order)}
|
|
733
|
+
return _wrap_tensor_result(
|
|
734
|
+
result,
|
|
735
|
+
remap_group_axes(self._symmetry, mapping),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
def swapaxes(self, axis1: int, axis2: int): # type: ignore[override]
|
|
739
|
+
order = list(range(self.ndim))
|
|
740
|
+
axis1 %= self.ndim
|
|
741
|
+
axis2 %= self.ndim
|
|
742
|
+
order[axis1], order[axis2] = order[axis2], order[axis1]
|
|
743
|
+
return self.transpose(tuple(order))
|
|
744
|
+
|
|
745
|
+
@property
|
|
746
|
+
def T(self):
|
|
747
|
+
return self.transpose()
|
|
748
|
+
|
|
749
|
+
# -- pickling --
|
|
750
|
+
|
|
751
|
+
def __reduce__(self):
|
|
752
|
+
pickled_state = super().__reduce__()
|
|
753
|
+
return (
|
|
754
|
+
pickled_state[0],
|
|
755
|
+
pickled_state[1],
|
|
756
|
+
pickled_state[2] + (self._symmetry,), # type: ignore[operator]
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
def __setstate__(self, state):
|
|
760
|
+
if len(state) < 2 or not isinstance(state[-1], SymmetryGroup):
|
|
761
|
+
raise ValueError("legacy symmetry payloads are not supported")
|
|
762
|
+
super().__setstate__(state[:-1])
|
|
763
|
+
self._symmetry = state[-1]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ---------------------------------------------------------------------------
|
|
767
|
+
# Factory
|
|
768
|
+
# ---------------------------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def as_symmetric(
|
|
772
|
+
data: np.ndarray,
|
|
773
|
+
*,
|
|
774
|
+
symmetry,
|
|
775
|
+
) -> SymmetricTensor:
|
|
776
|
+
"""Wrap *data* as a :class:`SymmetricTensor` after validating symmetry.
|
|
777
|
+
|
|
778
|
+
Parameters
|
|
779
|
+
----------
|
|
780
|
+
data : numpy.ndarray
|
|
781
|
+
The tensor data.
|
|
782
|
+
symmetry : SymmetryGroup or shorthand
|
|
783
|
+
Exact symmetry input accepted by :func:`normalize_symmetry_input`.
|
|
784
|
+
|
|
785
|
+
Returns
|
|
786
|
+
-------
|
|
787
|
+
SymmetricTensor
|
|
788
|
+
View of ``data`` carrying validated symmetry metadata.
|
|
789
|
+
|
|
790
|
+
Raises
|
|
791
|
+
------
|
|
792
|
+
SymmetryError
|
|
793
|
+
If the data does not satisfy the claimed symmetry.
|
|
794
|
+
|
|
795
|
+
Examples
|
|
796
|
+
--------
|
|
797
|
+
>>> import flopscope as flops
|
|
798
|
+
>>> import flopscope.numpy as fnp
|
|
799
|
+
>>>
|
|
800
|
+
>>> matrix = fnp.array([[1.0, 2.0], [2.0, 3.0]])
|
|
801
|
+
>>> tagged = flops.as_symmetric(matrix, (0, 1))
|
|
802
|
+
>>> tagged.symmetric_axes
|
|
803
|
+
[(0, 1)]
|
|
804
|
+
"""
|
|
805
|
+
group = _resolve_symmetry_argument(
|
|
806
|
+
data,
|
|
807
|
+
symmetry=symmetry,
|
|
808
|
+
)
|
|
809
|
+
assert group is not None # required=True raises if symmetry is None
|
|
810
|
+
array = np.asarray(data)
|
|
811
|
+
validate_symmetry_groups(array, [group])
|
|
812
|
+
return SymmetricTensor(array, symmetry=group)
|