sparsepixels 0.3.2__tar.gz → 0.3.3__tar.gz
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.
- {sparsepixels-0.3.2/sparsepixels.egg-info → sparsepixels-0.3.3}/PKG-INFO +30 -24
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/README.md +28 -23
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/setup.cfg +2 -1
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/sparsepixels/utils.py +151 -12
- {sparsepixels-0.3.2 → sparsepixels-0.3.3/sparsepixels.egg-info}/PKG-INFO +30 -24
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/sparsepixels.egg-info/requires.txt +1 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/LICENSE +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/notebook/utils.py +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/pyproject.toml +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/setup.py +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/sparsepixels/__init__.py +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/sparsepixels/img/logo.png +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/sparsepixels/layers.py +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/sparsepixels.egg-info/SOURCES.txt +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/sparsepixels.egg-info/dependency_links.txt +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/sparsepixels.egg-info/top_level.txt +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/tests/__init__.py +0 -0
- {sparsepixels-0.3.2 → sparsepixels-0.3.3}/tests/test_model.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sparsepixels
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Efficient convolution for sparse data on FPGAs
|
|
5
5
|
Home-page: https://github.com/hftsoi/sparse-pixels
|
|
6
6
|
Author: Ho Fung Tsoi
|
|
@@ -14,6 +14,7 @@ License-File: LICENSE
|
|
|
14
14
|
Requires-Dist: tensorflow
|
|
15
15
|
Requires-Dist: keras>=3.0
|
|
16
16
|
Requires-Dist: HGQ2>=0.1.8
|
|
17
|
+
Requires-Dist: quantizers<2
|
|
17
18
|
Requires-Dist: matplotlib
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
@@ -32,6 +33,7 @@ Dynamic: license-file
|
|
|
32
33
|
|
|
33
34
|
# SparsePixels: Efficient convolution for sparse data on FPGAs
|
|
34
35
|
|
|
36
|
+
[](https://hftsoi.github.io/sparse-pixels)
|
|
35
37
|
[](https://arxiv.org/abs/2512.06208)
|
|
36
38
|
[](https://pypi.org/project/sparsepixels)
|
|
37
39
|
|
|
@@ -59,24 +61,24 @@ active_pixels_vs_threshold(x_train)
|
|
|
59
61
|
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=3)
|
|
60
62
|
```
|
|
61
63
|
|
|
62
|
-
Build an example sparse CNN within HGQ2 quantization scopes.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
Build an example sparse CNN within HGQ2 quantization scopes. Set generous initial bit-widths at the
|
|
65
|
+
scope level: sparse signals are small (and get diluted further by pooling), so the low HGQ2 defaults
|
|
66
|
+
(4-bit weights, 2 fractional bits on activations) can quantize them, or entire low-magnitude
|
|
67
|
+
kernels, to exact zeros at initialization and block training. Starting wide (e.g. `b0=8` for
|
|
68
|
+
weights, `f0=8` for activations) keeps everything alive, and the EBOPS regularizer trims the
|
|
69
|
+
bit-widths back down during training. `InputReduce` keeps the first `n` active pixels (based on the
|
|
70
|
+
first channel above `threshold`) in a row-major order.
|
|
66
71
|
By default `n` and `threshold` are trainable hyperparameters: `beta_n` controls how aggressively `n` is driven smaller for a lower hardware footprint, and `beta_maskedE` penalizes over-masking pixel intensity.
|
|
67
72
|
|
|
68
73
|
```python
|
|
69
74
|
import keras
|
|
70
75
|
from hgq.layers import QDense
|
|
71
76
|
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
72
|
-
from hgq.quantizer.config import QuantizerConfig
|
|
73
77
|
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
74
78
|
|
|
75
|
-
iq_conf = QuantizerConfig(place='datalane', q_type='kif', i0=4, f0=8, overflow_mode='WRAP')
|
|
76
|
-
|
|
77
79
|
with (
|
|
78
|
-
QuantizerConfigScope(place='all', default_q_type='kbi', overflow_mode='SAT_SYM'),
|
|
79
|
-
QuantizerConfigScope(place='datalane', default_q_type='kif', overflow_mode='WRAP'),
|
|
80
|
+
QuantizerConfigScope(place='all', default_q_type='kbi', overflow_mode='SAT_SYM', b0=8, i0=0),
|
|
81
|
+
QuantizerConfigScope(place='datalane', default_q_type='kif', overflow_mode='WRAP', i0=4, f0=8),
|
|
80
82
|
LayerConfigScope(enable_ebops=True, enable_iq=True, beta0=1e-5),
|
|
81
83
|
):
|
|
82
84
|
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
@@ -91,14 +93,16 @@ with (
|
|
|
91
93
|
name='input_reduce',
|
|
92
94
|
)(x_in)
|
|
93
95
|
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
94
|
-
activation='relu'
|
|
96
|
+
activation='relu')([x, keep_mask])
|
|
95
97
|
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
96
98
|
|
|
97
99
|
x = keras.layers.Flatten(name='flatten')(x)
|
|
98
|
-
x = QDense(10, name='dense1', activation='relu'
|
|
100
|
+
x = QDense(10, name='dense1', activation='relu')(x)
|
|
99
101
|
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
100
102
|
|
|
101
103
|
model = keras.Model(x_in, x)
|
|
104
|
+
model.compile(optimizer=keras.optimizers.Adam(1e-3),
|
|
105
|
+
loss='categorical_crossentropy', metrics=['accuracy'])
|
|
102
106
|
```
|
|
103
107
|
|
|
104
108
|
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
@@ -108,13 +112,11 @@ budget/threshold, the EBOPS and the masked-intensity penalty each epoch, and it
|
|
|
108
112
|
```python
|
|
109
113
|
from sparsepixels.utils import SparseTrainingMonitor
|
|
110
114
|
|
|
111
|
-
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max',
|
|
112
|
-
|
|
113
|
-
optimizer=keras.optimizers.Adam(1e-3),
|
|
114
|
-
loss='categorical_crossentropy', metrics=['accuracy'],
|
|
115
|
-
)
|
|
115
|
+
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max',
|
|
116
|
+
patience=20, restore_best_weights=True)
|
|
116
117
|
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
117
|
-
epochs=100, batch_size=128,
|
|
118
|
+
epochs=100, batch_size=128,
|
|
119
|
+
callbacks=[early_stop, SparseTrainingMonitor()])
|
|
118
120
|
```
|
|
119
121
|
|
|
120
122
|
After training, plot the diagnostics from the monitoring tool. The final pixel budget and threshold values are stored in `InputReduce`: `layer.n_max_pixels` and `layer.threshold` (hls4ml converter will auto-parse these from the model).
|
|
@@ -132,10 +134,10 @@ print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
|
132
134
|
|
|
133
135
|
## Converting a trained model to HLS with hls4ml
|
|
134
136
|
|
|
135
|
-
> **Note:**
|
|
137
|
+
> **Note:** `sparsepixels` support is part of the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo ([PR #1468](https://github.com/fastmachinelearning/hls4ml/pull/1468), merged). It is not in a tagged release yet, so install hls4ml from the main branch:
|
|
136
138
|
>
|
|
137
139
|
> ```bash
|
|
138
|
-
> pip install "git+https://github.com/
|
|
140
|
+
> pip install "git+https://github.com/fastmachinelearning/hls4ml.git"
|
|
139
141
|
> ```
|
|
140
142
|
|
|
141
143
|
Once installed, pull a config from the trained model, optionally set the per-layer parallelization
|
|
@@ -174,7 +176,7 @@ y_hls = hls_model.predict(x_test)
|
|
|
174
176
|
|
|
175
177
|
## Documentation
|
|
176
178
|
|
|
177
|
-
|
|
179
|
+
See [here](https://hftsoi.github.io/sparse-pixels/) for documentation with walk-through guidance.
|
|
178
180
|
|
|
179
181
|
## Citation
|
|
180
182
|
|
|
@@ -183,11 +185,15 @@ If you find this useful in your research, please consider citing:
|
|
|
183
185
|
```
|
|
184
186
|
@article{Tsoi:2025nvg,
|
|
185
187
|
author = "Tsoi, Ho Fung and Rankin, Dylan and Loncar, Vladimir and Harris, Philip",
|
|
186
|
-
title = "{SparsePixels:
|
|
188
|
+
title = "{SparsePixels: efficient convolution for sparse data on FPGAs}",
|
|
187
189
|
eprint = "2512.06208",
|
|
188
190
|
archivePrefix = "arXiv",
|
|
189
191
|
primaryClass = "cs.AR",
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
doi = "10.1088/2632-2153/ae9fb5",
|
|
193
|
+
journal = "Mach. Learn. Sci. Tech.",
|
|
194
|
+
volume = "7",
|
|
195
|
+
number = "5",
|
|
196
|
+
pages = "055025",
|
|
197
|
+
year = "2026"
|
|
192
198
|
}
|
|
193
199
|
```
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
# SparsePixels: Efficient convolution for sparse data on FPGAs
|
|
15
15
|
|
|
16
|
+
[](https://hftsoi.github.io/sparse-pixels)
|
|
16
17
|
[](https://arxiv.org/abs/2512.06208)
|
|
17
18
|
[](https://pypi.org/project/sparsepixels)
|
|
18
19
|
|
|
@@ -40,24 +41,24 @@ active_pixels_vs_threshold(x_train)
|
|
|
40
41
|
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=3)
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
Build an example sparse CNN within HGQ2 quantization scopes.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
Build an example sparse CNN within HGQ2 quantization scopes. Set generous initial bit-widths at the
|
|
45
|
+
scope level: sparse signals are small (and get diluted further by pooling), so the low HGQ2 defaults
|
|
46
|
+
(4-bit weights, 2 fractional bits on activations) can quantize them, or entire low-magnitude
|
|
47
|
+
kernels, to exact zeros at initialization and block training. Starting wide (e.g. `b0=8` for
|
|
48
|
+
weights, `f0=8` for activations) keeps everything alive, and the EBOPS regularizer trims the
|
|
49
|
+
bit-widths back down during training. `InputReduce` keeps the first `n` active pixels (based on the
|
|
50
|
+
first channel above `threshold`) in a row-major order.
|
|
47
51
|
By default `n` and `threshold` are trainable hyperparameters: `beta_n` controls how aggressively `n` is driven smaller for a lower hardware footprint, and `beta_maskedE` penalizes over-masking pixel intensity.
|
|
48
52
|
|
|
49
53
|
```python
|
|
50
54
|
import keras
|
|
51
55
|
from hgq.layers import QDense
|
|
52
56
|
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
53
|
-
from hgq.quantizer.config import QuantizerConfig
|
|
54
57
|
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
55
58
|
|
|
56
|
-
iq_conf = QuantizerConfig(place='datalane', q_type='kif', i0=4, f0=8, overflow_mode='WRAP')
|
|
57
|
-
|
|
58
59
|
with (
|
|
59
|
-
QuantizerConfigScope(place='all', default_q_type='kbi', overflow_mode='SAT_SYM'),
|
|
60
|
-
QuantizerConfigScope(place='datalane', default_q_type='kif', overflow_mode='WRAP'),
|
|
60
|
+
QuantizerConfigScope(place='all', default_q_type='kbi', overflow_mode='SAT_SYM', b0=8, i0=0),
|
|
61
|
+
QuantizerConfigScope(place='datalane', default_q_type='kif', overflow_mode='WRAP', i0=4, f0=8),
|
|
61
62
|
LayerConfigScope(enable_ebops=True, enable_iq=True, beta0=1e-5),
|
|
62
63
|
):
|
|
63
64
|
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
@@ -72,14 +73,16 @@ with (
|
|
|
72
73
|
name='input_reduce',
|
|
73
74
|
)(x_in)
|
|
74
75
|
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
75
|
-
activation='relu'
|
|
76
|
+
activation='relu')([x, keep_mask])
|
|
76
77
|
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
77
78
|
|
|
78
79
|
x = keras.layers.Flatten(name='flatten')(x)
|
|
79
|
-
x = QDense(10, name='dense1', activation='relu'
|
|
80
|
+
x = QDense(10, name='dense1', activation='relu')(x)
|
|
80
81
|
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
81
82
|
|
|
82
83
|
model = keras.Model(x_in, x)
|
|
84
|
+
model.compile(optimizer=keras.optimizers.Adam(1e-3),
|
|
85
|
+
loss='categorical_crossentropy', metrics=['accuracy'])
|
|
83
86
|
```
|
|
84
87
|
|
|
85
88
|
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
@@ -89,13 +92,11 @@ budget/threshold, the EBOPS and the masked-intensity penalty each epoch, and it
|
|
|
89
92
|
```python
|
|
90
93
|
from sparsepixels.utils import SparseTrainingMonitor
|
|
91
94
|
|
|
92
|
-
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max',
|
|
93
|
-
|
|
94
|
-
optimizer=keras.optimizers.Adam(1e-3),
|
|
95
|
-
loss='categorical_crossentropy', metrics=['accuracy'],
|
|
96
|
-
)
|
|
95
|
+
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max',
|
|
96
|
+
patience=20, restore_best_weights=True)
|
|
97
97
|
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
98
|
-
epochs=100, batch_size=128,
|
|
98
|
+
epochs=100, batch_size=128,
|
|
99
|
+
callbacks=[early_stop, SparseTrainingMonitor()])
|
|
99
100
|
```
|
|
100
101
|
|
|
101
102
|
After training, plot the diagnostics from the monitoring tool. The final pixel budget and threshold values are stored in `InputReduce`: `layer.n_max_pixels` and `layer.threshold` (hls4ml converter will auto-parse these from the model).
|
|
@@ -113,10 +114,10 @@ print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
|
113
114
|
|
|
114
115
|
## Converting a trained model to HLS with hls4ml
|
|
115
116
|
|
|
116
|
-
> **Note:**
|
|
117
|
+
> **Note:** `sparsepixels` support is part of the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo ([PR #1468](https://github.com/fastmachinelearning/hls4ml/pull/1468), merged). It is not in a tagged release yet, so install hls4ml from the main branch:
|
|
117
118
|
>
|
|
118
119
|
> ```bash
|
|
119
|
-
> pip install "git+https://github.com/
|
|
120
|
+
> pip install "git+https://github.com/fastmachinelearning/hls4ml.git"
|
|
120
121
|
> ```
|
|
121
122
|
|
|
122
123
|
Once installed, pull a config from the trained model, optionally set the per-layer parallelization
|
|
@@ -155,7 +156,7 @@ y_hls = hls_model.predict(x_test)
|
|
|
155
156
|
|
|
156
157
|
## Documentation
|
|
157
158
|
|
|
158
|
-
|
|
159
|
+
See [here](https://hftsoi.github.io/sparse-pixels/) for documentation with walk-through guidance.
|
|
159
160
|
|
|
160
161
|
## Citation
|
|
161
162
|
|
|
@@ -164,11 +165,15 @@ If you find this useful in your research, please consider citing:
|
|
|
164
165
|
```
|
|
165
166
|
@article{Tsoi:2025nvg,
|
|
166
167
|
author = "Tsoi, Ho Fung and Rankin, Dylan and Loncar, Vladimir and Harris, Philip",
|
|
167
|
-
title = "{SparsePixels:
|
|
168
|
+
title = "{SparsePixels: efficient convolution for sparse data on FPGAs}",
|
|
168
169
|
eprint = "2512.06208",
|
|
169
170
|
archivePrefix = "arXiv",
|
|
170
171
|
primaryClass = "cs.AR",
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
doi = "10.1088/2632-2153/ae9fb5",
|
|
173
|
+
journal = "Mach. Learn. Sci. Tech.",
|
|
174
|
+
volume = "7",
|
|
175
|
+
number = "5",
|
|
176
|
+
pages = "055025",
|
|
177
|
+
year = "2026"
|
|
173
178
|
}
|
|
174
179
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[metadata]
|
|
2
2
|
name = sparsepixels
|
|
3
|
-
version = 0.3.
|
|
3
|
+
version = 0.3.3
|
|
4
4
|
description = Efficient convolution for sparse data on FPGAs
|
|
5
5
|
author = Ho Fung Tsoi
|
|
6
6
|
author_email = ho.fung.tsoi@cern.ch
|
|
@@ -20,6 +20,7 @@ install_requires =
|
|
|
20
20
|
tensorflow
|
|
21
21
|
keras>=3.0
|
|
22
22
|
HGQ2>=0.1.8
|
|
23
|
+
quantizers<2
|
|
23
24
|
matplotlib
|
|
24
25
|
include_package_data = True
|
|
25
26
|
|
|
@@ -9,6 +9,9 @@ set_sparse_ebops_factor corrects each sparse conv's EBOPS for the fact that it o
|
|
|
9
9
|
active pixels, so the monitored EBOPS and the EBOPS regularizer reflect the real sparse cost.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
import keras
|
|
12
15
|
import matplotlib.pyplot as plt
|
|
13
16
|
import numpy as np
|
|
14
17
|
from keras import ops
|
|
@@ -214,7 +217,7 @@ class SparseTrainingMonitor(Callback):
|
|
|
214
217
|
logs["loss_task"] = logs["loss"] - reg_ebops - reg_n - reg_maskedE
|
|
215
218
|
|
|
216
219
|
|
|
217
|
-
def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
220
|
+
def plot_history(history, early_stopping=None, figsize=None, ncols=3, save_path=None):
|
|
218
221
|
"""Plot the training history recorded by SparseTrainingMonitor in one figure.
|
|
219
222
|
|
|
220
223
|
Shows the total loss and each compiled metric (train, and validation when present), the loss
|
|
@@ -308,10 +311,14 @@ def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
|
308
311
|
for ax in axes[n:]:
|
|
309
312
|
ax.axis("off")
|
|
310
313
|
fig.tight_layout()
|
|
314
|
+
if save_path is not None:
|
|
315
|
+
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
|
311
316
|
plt.show()
|
|
312
317
|
|
|
313
318
|
|
|
314
|
-
def active_pixels_vs_threshold(
|
|
319
|
+
def active_pixels_vs_threshold(
|
|
320
|
+
x, thresholds=None, channel=0, percentiles=(25, 50, 75), plot=True, ax=None, save_path=None
|
|
321
|
+
):
|
|
315
322
|
"""Study how the number of active pixels per image changes with the threshold.
|
|
316
323
|
|
|
317
324
|
For each candidate threshold, counts the active pixels (x[..., channel] > threshold) in every
|
|
@@ -349,7 +356,7 @@ def active_pixels_vs_threshold(x, thresholds=None, channel=0, percentiles=(25, 5
|
|
|
349
356
|
if plot:
|
|
350
357
|
owns_fig = ax is None
|
|
351
358
|
if ax is None:
|
|
352
|
-
|
|
359
|
+
fig, ax = plt.subplots(figsize=(7, 4.5))
|
|
353
360
|
ax.plot(thresholds, stats["mean"], color="black", lw=2, label="mean")
|
|
354
361
|
ax.plot(thresholds, stats["min"], "--", alpha=0.7, label="min")
|
|
355
362
|
for p in percentiles:
|
|
@@ -361,11 +368,15 @@ def active_pixels_vs_threshold(x, thresholds=None, channel=0, percentiles=(25, 5
|
|
|
361
368
|
ax.legend(loc="best")
|
|
362
369
|
ax.grid(alpha=0.2)
|
|
363
370
|
if owns_fig:
|
|
371
|
+
if save_path is not None:
|
|
372
|
+
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
|
364
373
|
plt.show()
|
|
365
374
|
return stats
|
|
366
375
|
|
|
367
376
|
|
|
368
|
-
def plot_reduced_examples(
|
|
377
|
+
def plot_reduced_examples(
|
|
378
|
+
x, n, threshold, indices=None, n_examples=5, channel=0, figsize=None, aspect=None, cmap="viridis", save_path=None
|
|
379
|
+
):
|
|
369
380
|
"""Show what InputReduce keeps for a given budget n and threshold on a few images.
|
|
370
381
|
|
|
371
382
|
Each row shows the original image and, at the same intensity scale, the pixels that are kept (the
|
|
@@ -380,12 +391,19 @@ def plot_reduced_examples(x, n, threshold, indices=None, n_examples=5, channel=0
|
|
|
380
391
|
n_examples: number of images to show when indices is not given.
|
|
381
392
|
channel: channel used to decide activity (default 0).
|
|
382
393
|
figsize: optional (width, height).
|
|
394
|
+
aspect: imshow aspect: None keeps the natural pixel ratio, "auto" stretches each image to
|
|
395
|
+
fill its panel (useful for very wide images), or a float scales the pixel height.
|
|
396
|
+
cmap: colormap for the active pixels; empty pixels (at or below 0) are drawn white. For
|
|
397
|
+
binary images a reversed map like "viridis_r" makes the hits dark on white.
|
|
383
398
|
"""
|
|
384
399
|
x = np.asarray(x)
|
|
385
400
|
if indices is None:
|
|
386
401
|
indices = np.arange(min(n_examples, len(x)))
|
|
387
402
|
indices = list(indices)
|
|
388
403
|
|
|
404
|
+
cmap_obj = plt.get_cmap(cmap).copy()
|
|
405
|
+
cmap_obj.set_bad("white")
|
|
406
|
+
|
|
389
407
|
fig, axes = plt.subplots(len(indices), 2, figsize=figsize or (6, 3 * len(indices)), squeeze=False)
|
|
390
408
|
for row, idx in enumerate(indices):
|
|
391
409
|
s = x[idx][..., channel]
|
|
@@ -395,16 +413,135 @@ def plot_reduced_examples(x, n, threshold, indices=None, n_examples=5, channel=0
|
|
|
395
413
|
n_active, n_kept = int(active.sum()), int(keep.sum())
|
|
396
414
|
vmax = float(s.max()) or 1.0
|
|
397
415
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
for a in axes[row]:
|
|
403
|
-
a.
|
|
416
|
+
panels = [
|
|
417
|
+
(s, f"image #{idx}"),
|
|
418
|
+
(np.where(keep, s, 0.0), f"kept (n={n}, thr={threshold:g}): {n_kept} of {n_active} active"),
|
|
419
|
+
]
|
|
420
|
+
for a, (img, title) in zip(axes[row], panels):
|
|
421
|
+
a.imshow(
|
|
422
|
+
np.ma.masked_less_equal(img, 0.0),
|
|
423
|
+
cmap=cmap_obj,
|
|
424
|
+
vmin=0,
|
|
425
|
+
vmax=vmax,
|
|
426
|
+
aspect=aspect if aspect is not None else "equal",
|
|
427
|
+
interpolation="nearest",
|
|
428
|
+
)
|
|
429
|
+
a.set_title(title)
|
|
430
|
+
a.set_xticks([])
|
|
431
|
+
a.set_yticks([])
|
|
404
432
|
fig.tight_layout()
|
|
433
|
+
if save_path is not None:
|
|
434
|
+
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
|
405
435
|
plt.show()
|
|
406
436
|
|
|
407
437
|
|
|
438
|
+
def freeze_quantization(model, freeze_budget=True):
|
|
439
|
+
"""Freeze the quantization (and optionally the pixel budget) at the current learned values.
|
|
440
|
+
|
|
441
|
+
Sets every quantizer sublayer non-trainable and zeroes the WRAP integer-part decay, so the
|
|
442
|
+
bit-width configuration stays exactly fixed afterwards; optionally also freezes the InputReduce
|
|
443
|
+
budget. Use for two-stage training: after the quantization-aware stage converges, freeze,
|
|
444
|
+
recompile with a lower learning rate, and fine-tune. The weights then adapt to the final bit
|
|
445
|
+
assignment instead of a moving one, and early stopping effectively monitors the pure task loss
|
|
446
|
+
since the frozen regularizer terms are constants.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
model: a keras model built with the quantized (HGQ) layers.
|
|
450
|
+
freeze_budget: also set InputReduce layers non-trainable (a learnable budget n stops moving).
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
int: the number of layers set non-trainable. Remember to call model.compile again.
|
|
454
|
+
"""
|
|
455
|
+
n_frozen = 0
|
|
456
|
+
for sub in model._flatten_layers(include_self=False):
|
|
457
|
+
cls = sub.__class__
|
|
458
|
+
is_quantizer = "quantizer" in cls.__module__.lower() or "Quantizer" in cls.__name__
|
|
459
|
+
if is_quantizer or (freeze_budget and isinstance(sub, InputReduce)):
|
|
460
|
+
sub.trainable = False
|
|
461
|
+
n_frozen += 1
|
|
462
|
+
for w in model.weights:
|
|
463
|
+
if "i_decay" in w.path:
|
|
464
|
+
w.assign(np.zeros(w.shape))
|
|
465
|
+
return n_frozen
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def plot_layer_outputs(model, x, index=0, layers=None, cmap="viridis", aspect="auto", max_channels=16, save_path=None):
|
|
469
|
+
"""Plot every layer's output for one input image, channel by channel.
|
|
470
|
+
|
|
471
|
+
Feeds one image through the model and draws one figure per layer, with that layer's channels
|
|
472
|
+
stacked as rows on a common intensity scale (empty pixels in white) and the active-pixel count
|
|
473
|
+
annotated per channel. Use it to spot where the sparse signals get over-compressed, e.g. a
|
|
474
|
+
pooling layer merging the few active pixels of a track into far fewer, and adjust the
|
|
475
|
+
architecture accordingly.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
model: a functional keras model.
|
|
479
|
+
x: image array of shape (N, H, W, C) or a single image (H, W, C).
|
|
480
|
+
index: which image of x to feed.
|
|
481
|
+
layers: layer names to show; defaults to every layer with a plottable output.
|
|
482
|
+
cmap: colormap for the active values; exact zeros are drawn white.
|
|
483
|
+
aspect: imshow aspect for the feature-map panels ("auto" fills each panel, None keeps the
|
|
484
|
+
natural pixel ratio, a float scales the pixel height).
|
|
485
|
+
max_channels: cap on the number of channels drawn per layer.
|
|
486
|
+
save_path: optional path; each layer's figure is saved with the layer name appended.
|
|
487
|
+
"""
|
|
488
|
+
x = np.asarray(x, dtype="float32")
|
|
489
|
+
if x.ndim == 3:
|
|
490
|
+
x = x[None]
|
|
491
|
+
xb = x[index : index + 1]
|
|
492
|
+
|
|
493
|
+
outputs = {}
|
|
494
|
+
for layer in model.layers:
|
|
495
|
+
if layer.__class__.__name__ == "InputLayer":
|
|
496
|
+
continue
|
|
497
|
+
if layers is not None and layer.name not in layers:
|
|
498
|
+
continue
|
|
499
|
+
try:
|
|
500
|
+
out = layer.output
|
|
501
|
+
except Exception:
|
|
502
|
+
continue
|
|
503
|
+
if isinstance(out, (list, tuple)):
|
|
504
|
+
out = out[0]
|
|
505
|
+
outputs[layer.name] = out
|
|
506
|
+
probe = keras.Model(model.inputs, outputs)
|
|
507
|
+
acts = probe.predict(xb, verbose=0)
|
|
508
|
+
|
|
509
|
+
cmap_obj = plt.get_cmap(cmap).copy()
|
|
510
|
+
cmap_obj.set_bad("white")
|
|
511
|
+
|
|
512
|
+
for name, act in acts.items():
|
|
513
|
+
a = np.asarray(act)[0]
|
|
514
|
+
if a.ndim == 1:
|
|
515
|
+
a = a[None, :, None]
|
|
516
|
+
elif a.ndim != 3:
|
|
517
|
+
continue
|
|
518
|
+
n_ch = min(a.shape[-1], max_channels)
|
|
519
|
+
vmin, vmax = min(0.0, float(a.min())), float(a.max()) or 1.0
|
|
520
|
+
|
|
521
|
+
fig, axes = plt.subplots(n_ch, 1, figsize=(14, 0.75 * n_ch + 0.6), squeeze=False)
|
|
522
|
+
for ch in range(n_ch):
|
|
523
|
+
img, ax = a[..., ch], axes[ch][0]
|
|
524
|
+
ax.imshow(
|
|
525
|
+
np.ma.masked_equal(img, 0.0),
|
|
526
|
+
cmap=cmap_obj,
|
|
527
|
+
vmin=vmin,
|
|
528
|
+
vmax=vmax,
|
|
529
|
+
aspect=aspect if aspect is not None else "equal",
|
|
530
|
+
interpolation="nearest",
|
|
531
|
+
origin="lower",
|
|
532
|
+
)
|
|
533
|
+
ax.set_ylabel(f"ch {ch}\n{int((img != 0).sum())} act", fontsize=7, rotation=0, ha="right", va="center")
|
|
534
|
+
ax.set_xticks([])
|
|
535
|
+
ax.set_yticks([])
|
|
536
|
+
shown = f", showing {n_ch} of {a.shape[-1]} channels" if n_ch < a.shape[-1] else ""
|
|
537
|
+
axes[0][0].set_title(f"{name}: output {tuple(np.asarray(act).shape[1:])}{shown}", loc="left", fontsize=10)
|
|
538
|
+
fig.tight_layout()
|
|
539
|
+
if save_path is not None:
|
|
540
|
+
root, ext = os.path.splitext(save_path)
|
|
541
|
+
fig.savefig(f"{root}_{name}{ext or '.png'}", dpi=150, bbox_inches="tight")
|
|
542
|
+
plt.show()
|
|
543
|
+
|
|
544
|
+
|
|
408
545
|
def print_quantization(model):
|
|
409
546
|
"""Print the per-layer quantizer bit-widths (kernel, bias, input as mean, min, max) and EBOPS.
|
|
410
547
|
|
|
@@ -446,7 +583,7 @@ def print_quantization(model):
|
|
|
446
583
|
print(f"{'Total eBOPs':>{len(header) - 10}}{total_ebops:>10.0f}")
|
|
447
584
|
|
|
448
585
|
|
|
449
|
-
def plot_quantization(models, figsize=(14, 3)):
|
|
586
|
+
def plot_quantization(models, figsize=(14, 3), save_path=None):
|
|
450
587
|
"""Violin plots of the per-layer kernel, bias and input bit-width distributions.
|
|
451
588
|
|
|
452
589
|
Args:
|
|
@@ -456,7 +593,7 @@ def plot_quantization(models, figsize=(14, 3)):
|
|
|
456
593
|
if not isinstance(models, (list, tuple)):
|
|
457
594
|
models = [models]
|
|
458
595
|
categories = [("kq", "Kernel bits"), ("bq", "Bias bits"), ("iq", "Input bits")]
|
|
459
|
-
|
|
596
|
+
fig, axes = plt.subplots(
|
|
460
597
|
len(models),
|
|
461
598
|
len(categories),
|
|
462
599
|
figsize=(figsize[0], figsize[1] * len(models)),
|
|
@@ -492,4 +629,6 @@ def plot_quantization(models, figsize=(14, 3)):
|
|
|
492
629
|
pad = max(0.5, 0.08 * (hi - lo)) # show the full band with a small margin at both ends
|
|
493
630
|
ax.set_xlim(lo - pad, hi + pad)
|
|
494
631
|
ax.set_title(f"{model.name} - {title}")
|
|
632
|
+
if save_path is not None:
|
|
633
|
+
fig.savefig(save_path, dpi=150, bbox_inches="tight")
|
|
495
634
|
plt.show()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sparsepixels
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Efficient convolution for sparse data on FPGAs
|
|
5
5
|
Home-page: https://github.com/hftsoi/sparse-pixels
|
|
6
6
|
Author: Ho Fung Tsoi
|
|
@@ -14,6 +14,7 @@ License-File: LICENSE
|
|
|
14
14
|
Requires-Dist: tensorflow
|
|
15
15
|
Requires-Dist: keras>=3.0
|
|
16
16
|
Requires-Dist: HGQ2>=0.1.8
|
|
17
|
+
Requires-Dist: quantizers<2
|
|
17
18
|
Requires-Dist: matplotlib
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
@@ -32,6 +33,7 @@ Dynamic: license-file
|
|
|
32
33
|
|
|
33
34
|
# SparsePixels: Efficient convolution for sparse data on FPGAs
|
|
34
35
|
|
|
36
|
+
[](https://hftsoi.github.io/sparse-pixels)
|
|
35
37
|
[](https://arxiv.org/abs/2512.06208)
|
|
36
38
|
[](https://pypi.org/project/sparsepixels)
|
|
37
39
|
|
|
@@ -59,24 +61,24 @@ active_pixels_vs_threshold(x_train)
|
|
|
59
61
|
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=3)
|
|
60
62
|
```
|
|
61
63
|
|
|
62
|
-
Build an example sparse CNN within HGQ2 quantization scopes.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
Build an example sparse CNN within HGQ2 quantization scopes. Set generous initial bit-widths at the
|
|
65
|
+
scope level: sparse signals are small (and get diluted further by pooling), so the low HGQ2 defaults
|
|
66
|
+
(4-bit weights, 2 fractional bits on activations) can quantize them, or entire low-magnitude
|
|
67
|
+
kernels, to exact zeros at initialization and block training. Starting wide (e.g. `b0=8` for
|
|
68
|
+
weights, `f0=8` for activations) keeps everything alive, and the EBOPS regularizer trims the
|
|
69
|
+
bit-widths back down during training. `InputReduce` keeps the first `n` active pixels (based on the
|
|
70
|
+
first channel above `threshold`) in a row-major order.
|
|
66
71
|
By default `n` and `threshold` are trainable hyperparameters: `beta_n` controls how aggressively `n` is driven smaller for a lower hardware footprint, and `beta_maskedE` penalizes over-masking pixel intensity.
|
|
67
72
|
|
|
68
73
|
```python
|
|
69
74
|
import keras
|
|
70
75
|
from hgq.layers import QDense
|
|
71
76
|
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
72
|
-
from hgq.quantizer.config import QuantizerConfig
|
|
73
77
|
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
74
78
|
|
|
75
|
-
iq_conf = QuantizerConfig(place='datalane', q_type='kif', i0=4, f0=8, overflow_mode='WRAP')
|
|
76
|
-
|
|
77
79
|
with (
|
|
78
|
-
QuantizerConfigScope(place='all', default_q_type='kbi', overflow_mode='SAT_SYM'),
|
|
79
|
-
QuantizerConfigScope(place='datalane', default_q_type='kif', overflow_mode='WRAP'),
|
|
80
|
+
QuantizerConfigScope(place='all', default_q_type='kbi', overflow_mode='SAT_SYM', b0=8, i0=0),
|
|
81
|
+
QuantizerConfigScope(place='datalane', default_q_type='kif', overflow_mode='WRAP', i0=4, f0=8),
|
|
80
82
|
LayerConfigScope(enable_ebops=True, enable_iq=True, beta0=1e-5),
|
|
81
83
|
):
|
|
82
84
|
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
@@ -91,14 +93,16 @@ with (
|
|
|
91
93
|
name='input_reduce',
|
|
92
94
|
)(x_in)
|
|
93
95
|
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
94
|
-
activation='relu'
|
|
96
|
+
activation='relu')([x, keep_mask])
|
|
95
97
|
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
96
98
|
|
|
97
99
|
x = keras.layers.Flatten(name='flatten')(x)
|
|
98
|
-
x = QDense(10, name='dense1', activation='relu'
|
|
100
|
+
x = QDense(10, name='dense1', activation='relu')(x)
|
|
99
101
|
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
100
102
|
|
|
101
103
|
model = keras.Model(x_in, x)
|
|
104
|
+
model.compile(optimizer=keras.optimizers.Adam(1e-3),
|
|
105
|
+
loss='categorical_crossentropy', metrics=['accuracy'])
|
|
102
106
|
```
|
|
103
107
|
|
|
104
108
|
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
@@ -108,13 +112,11 @@ budget/threshold, the EBOPS and the masked-intensity penalty each epoch, and it
|
|
|
108
112
|
```python
|
|
109
113
|
from sparsepixels.utils import SparseTrainingMonitor
|
|
110
114
|
|
|
111
|
-
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max',
|
|
112
|
-
|
|
113
|
-
optimizer=keras.optimizers.Adam(1e-3),
|
|
114
|
-
loss='categorical_crossentropy', metrics=['accuracy'],
|
|
115
|
-
)
|
|
115
|
+
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max',
|
|
116
|
+
patience=20, restore_best_weights=True)
|
|
116
117
|
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
117
|
-
epochs=100, batch_size=128,
|
|
118
|
+
epochs=100, batch_size=128,
|
|
119
|
+
callbacks=[early_stop, SparseTrainingMonitor()])
|
|
118
120
|
```
|
|
119
121
|
|
|
120
122
|
After training, plot the diagnostics from the monitoring tool. The final pixel budget and threshold values are stored in `InputReduce`: `layer.n_max_pixels` and `layer.threshold` (hls4ml converter will auto-parse these from the model).
|
|
@@ -132,10 +134,10 @@ print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
|
132
134
|
|
|
133
135
|
## Converting a trained model to HLS with hls4ml
|
|
134
136
|
|
|
135
|
-
> **Note:**
|
|
137
|
+
> **Note:** `sparsepixels` support is part of the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo ([PR #1468](https://github.com/fastmachinelearning/hls4ml/pull/1468), merged). It is not in a tagged release yet, so install hls4ml from the main branch:
|
|
136
138
|
>
|
|
137
139
|
> ```bash
|
|
138
|
-
> pip install "git+https://github.com/
|
|
140
|
+
> pip install "git+https://github.com/fastmachinelearning/hls4ml.git"
|
|
139
141
|
> ```
|
|
140
142
|
|
|
141
143
|
Once installed, pull a config from the trained model, optionally set the per-layer parallelization
|
|
@@ -174,7 +176,7 @@ y_hls = hls_model.predict(x_test)
|
|
|
174
176
|
|
|
175
177
|
## Documentation
|
|
176
178
|
|
|
177
|
-
|
|
179
|
+
See [here](https://hftsoi.github.io/sparse-pixels/) for documentation with walk-through guidance.
|
|
178
180
|
|
|
179
181
|
## Citation
|
|
180
182
|
|
|
@@ -183,11 +185,15 @@ If you find this useful in your research, please consider citing:
|
|
|
183
185
|
```
|
|
184
186
|
@article{Tsoi:2025nvg,
|
|
185
187
|
author = "Tsoi, Ho Fung and Rankin, Dylan and Loncar, Vladimir and Harris, Philip",
|
|
186
|
-
title = "{SparsePixels:
|
|
188
|
+
title = "{SparsePixels: efficient convolution for sparse data on FPGAs}",
|
|
187
189
|
eprint = "2512.06208",
|
|
188
190
|
archivePrefix = "arXiv",
|
|
189
191
|
primaryClass = "cs.AR",
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
doi = "10.1088/2632-2153/ae9fb5",
|
|
193
|
+
journal = "Mach. Learn. Sci. Tech.",
|
|
194
|
+
volume = "7",
|
|
195
|
+
number = "5",
|
|
196
|
+
pages = "055025",
|
|
197
|
+
year = "2026"
|
|
192
198
|
}
|
|
193
199
|
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|