sparsepixels 0.3.1__tar.gz → 0.3.2__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.1/sparsepixels.egg-info → sparsepixels-0.3.2}/PKG-INFO +49 -61
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/README.md +48 -60
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/notebook/utils.py +97 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/setup.cfg +1 -1
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/sparsepixels/layers.py +33 -5
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/sparsepixels/utils.py +26 -27
- {sparsepixels-0.3.1 → sparsepixels-0.3.2/sparsepixels.egg-info}/PKG-INFO +49 -61
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/sparsepixels.egg-info/top_level.txt +0 -1
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/tests/test_model.py +4 -5
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/LICENSE +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/pyproject.toml +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/setup.py +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/sparsepixels/__init__.py +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/sparsepixels/img/logo.png +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/sparsepixels.egg-info/SOURCES.txt +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/sparsepixels.egg-info/dependency_links.txt +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/sparsepixels.egg-info/requires.txt +0 -0
- {sparsepixels-0.3.1 → sparsepixels-0.3.2}/tests/__init__.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.2
|
|
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
|
|
@@ -35,7 +35,7 @@ Dynamic: license-file
|
|
|
35
35
|
[](https://arxiv.org/abs/2512.06208)
|
|
36
36
|
[](https://pypi.org/project/sparsepixels)
|
|
37
37
|
|
|
38
|
-
SparsePixels is a Keras 3 library to build, train, and deploy sparse convolutional neural networks on FPGAs. In many detectors, especially in high-energy physics experiments, the images are almost empty: only a handful of pixels carry a signal (the hits), yet a standard CNN still spends compute on every pixel. A sparse CNN convolves only over the active pixels, so its cost scales with the number of hits rather than the image size, which is what makes low-latency, real-time inference (
|
|
38
|
+
SparsePixels is a Keras 3 library to build, train, and deploy sparse convolutional neural networks on FPGAs. In many detectors, especially in high-energy physics experiments, the images are almost empty: only a handful of pixels carry a signal (the hits), yet a standard CNN still spends compute on every pixel. A sparse CNN convolves only over the active pixels, so its cost scales with the number of hits rather than the image size, which is what makes low-latency, real-time inference (e.g. in a trigger) feasible on an FPGA. This library builds quantization-aware (with [HGQ2](https://github.com/calad0i/HGQ2) as a backend) sparse CNNs in which the pixel budget and the activity threshold can be learned from data, with a hardware-aware penalty that drives the budget toward the fewest pixels the task tolerates. Trained models convert to FPGA firmware through the [hls4ml](https://github.com/fastmachinelearning/hls4ml) integration, with control over the parallelization of the sparse layers so user can trade latency against resource usage. This training library is designed to mirror our HLS design with bit-exact accuracy.
|
|
39
39
|
|
|
40
40
|
## Installation
|
|
41
41
|
|
|
@@ -45,36 +45,33 @@ With Python >= 3.10:
|
|
|
45
45
|
pip install sparsepixels
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
This includes all necessary backends (tensorflow, keras, HGQ2).
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
import keras
|
|
52
|
-
from keras.layers import Flatten, Activation
|
|
53
|
-
from hgq.layers import QDense
|
|
54
|
-
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
55
|
-
from hgq.quantizer.config import QuantizerConfig
|
|
56
|
-
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
57
|
-
from sparsepixels.utils import (
|
|
58
|
-
active_pixels_vs_threshold, plot_reduced_examples, cosine_lr,
|
|
59
|
-
SparseTrainingMonitor, plot_history, print_quantization, plot_quantization,
|
|
60
|
-
)
|
|
61
|
-
```
|
|
50
|
+
## Getting Started
|
|
62
51
|
|
|
63
52
|
First, study the data to pick a threshold and an initial pixel budget `n`: how many pixels stay
|
|
64
|
-
active as the threshold rises, and what a candidate `(n, threshold)`
|
|
53
|
+
active as the threshold rises, and what a candidate `(n, threshold)` maskes out pixels on a few example images.
|
|
65
54
|
|
|
66
55
|
```python
|
|
56
|
+
from sparsepixels.utils import active_pixels_vs_threshold, plot_reduced_examples
|
|
57
|
+
|
|
67
58
|
active_pixels_vs_threshold(x_train)
|
|
68
|
-
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=
|
|
59
|
+
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=3)
|
|
69
60
|
```
|
|
70
61
|
|
|
71
62
|
Build an example sparse CNN within HGQ2 quantization scopes. A custom input quantizer config with
|
|
72
|
-
higher initial fractional bits (`f0=8`) prevents the default (`f0=2`) from zeroing out sparse signals
|
|
73
|
-
in early training epochs. `InputReduce` keeps the first `n` active pixels (first channel above
|
|
74
|
-
`threshold`)
|
|
75
|
-
|
|
63
|
+
higher initial fractional bits (e.g., `f0=8`) prevents the lower default (`f0=2`) from aggressively zeroing out sparse signals
|
|
64
|
+
in early training epochs. `InputReduce` keeps the first `n` active pixels (based on the first channel above
|
|
65
|
+
`threshold`) in a row-major order.
|
|
66
|
+
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.
|
|
76
67
|
|
|
77
68
|
```python
|
|
69
|
+
import keras
|
|
70
|
+
from hgq.layers import QDense
|
|
71
|
+
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
72
|
+
from hgq.quantizer.config import QuantizerConfig
|
|
73
|
+
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')
|
|
79
76
|
|
|
80
77
|
with (
|
|
@@ -84,54 +81,49 @@ with (
|
|
|
84
81
|
):
|
|
85
82
|
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
86
83
|
|
|
87
|
-
# Sparse input reduction
|
|
88
84
|
x, keep_mask = InputReduce(
|
|
89
|
-
n=30,
|
|
90
|
-
threshold=0.1,
|
|
91
|
-
beta_n=
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
n=30, # initial pixel budget
|
|
86
|
+
threshold=0.1, # initial activity threshold
|
|
87
|
+
beta_n=5e-3, # higher -> drives the pixel budget n smaller
|
|
88
|
+
beta_maskedE=1.0, # higher -> prevents over-masking
|
|
89
|
+
learn_n=True, # trainable pixel budget
|
|
90
|
+
learn_threshold=True, # trainable threshold
|
|
94
91
|
name='input_reduce',
|
|
95
92
|
)(x_in)
|
|
96
|
-
|
|
97
|
-
# Sparse convolution
|
|
98
93
|
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
99
94
|
activation='relu', iq_conf=iq_conf)([x, keep_mask])
|
|
100
|
-
|
|
101
|
-
# Sparse pooling
|
|
102
95
|
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
103
96
|
|
|
104
|
-
x = Flatten(name='flatten')(x)
|
|
97
|
+
x = keras.layers.Flatten(name='flatten')(x)
|
|
105
98
|
x = QDense(10, name='dense1', activation='relu', iq_conf=iq_conf)(x)
|
|
106
|
-
x = Activation('softmax', name='softmax')(x)
|
|
99
|
+
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
107
100
|
|
|
108
101
|
model = keras.Model(x_in, x)
|
|
109
102
|
```
|
|
110
103
|
|
|
111
104
|
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
112
|
-
budget/threshold and the
|
|
113
|
-
hardware cost) to the sparse compute automatically
|
|
114
|
-
only sparse-specific choices are the cosine-decayed learning rate and `restore_best_weights`, which
|
|
115
|
-
keep the learned budget from over-compressing near the end of training.
|
|
105
|
+
budget/threshold, the EBOPS and the masked-intensity penalty each epoch, and it corrects the EBOPS
|
|
106
|
+
(a proxy for the quantized hardware cost) to the sparse compute automatically.
|
|
116
107
|
|
|
117
108
|
```python
|
|
109
|
+
from sparsepixels.utils import SparseTrainingMonitor
|
|
110
|
+
|
|
118
111
|
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max', patience=20, restore_best_weights=True)
|
|
119
112
|
model.compile(
|
|
120
|
-
optimizer=keras.optimizers.Adam(
|
|
113
|
+
optimizer=keras.optimizers.Adam(1e-3),
|
|
121
114
|
loss='categorical_crossentropy', metrics=['accuracy'],
|
|
122
115
|
)
|
|
123
116
|
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
124
117
|
epochs=100, batch_size=128, callbacks=[early_stop, SparseTrainingMonitor()])
|
|
125
118
|
```
|
|
126
119
|
|
|
127
|
-
After training, plot the diagnostics and
|
|
128
|
-
shows the loss breakdown, the learned budget/threshold and the EBOPS in one figure.
|
|
129
|
-
`print_quantization` / `plot_quantization` summarize the per-layer bit-widths. The values to
|
|
130
|
-
deploy are `layer.n_max_pixels` and `layer.threshold` (hls4ml converter will auto-parse these from the model).
|
|
120
|
+
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).
|
|
131
121
|
|
|
132
122
|
```python
|
|
133
|
-
|
|
134
|
-
|
|
123
|
+
from sparsepixels.utils import plot_history, print_quantization, plot_quantization
|
|
124
|
+
|
|
125
|
+
plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
|
|
126
|
+
print_quantization(model) # per-layer bit-width distribution and EBOPS
|
|
135
127
|
plot_quantization(model)
|
|
136
128
|
|
|
137
129
|
ir = model.get_layer('input_reduce')
|
|
@@ -140,7 +132,7 @@ print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
|
140
132
|
|
|
141
133
|
## Converting a trained model to HLS with hls4ml
|
|
142
134
|
|
|
143
|
-
> **Note:** A [PR](https://github.com/fastmachinelearning/hls4ml/pull/1468) adding `sparsepixels` support to the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo has been submitted but is not yet merged. In the meantime you can install hls4ml from the PR branch on this fork to
|
|
135
|
+
> **Note:** A [PR](https://github.com/fastmachinelearning/hls4ml/pull/1468) adding `sparsepixels` support to the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo has been submitted but is not yet merged. In the meantime you can install hls4ml from the PR branch on this fork to use the converter:
|
|
144
136
|
>
|
|
145
137
|
> ```bash
|
|
146
138
|
> pip install "git+https://github.com/hftsoi/hls4ml.git@sparsepixels"
|
|
@@ -155,22 +147,18 @@ import hls4ml
|
|
|
155
147
|
hls_config = hls4ml.utils.config_from_keras_model(model, granularity='name')
|
|
156
148
|
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' # "#pragma HLS DATAFLOW"
|
|
157
149
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
'flatten': {'ParallelFactor': 14 * 14}, # this model pools 28*28 -> 14*14
|
|
171
|
-
}
|
|
172
|
-
for name, cfg in knobs.items():
|
|
173
|
-
hls_config['LayerName'].setdefault(name, {}).update(cfg)
|
|
150
|
+
n_max_pixels = model.get_layer('input_reduce').n_max_pixels
|
|
151
|
+
|
|
152
|
+
# input reduce: 'tree' (default, lowest latency) or 'stream' (sequential, fewer resources)
|
|
153
|
+
hls_config['LayerName']['input_reduce']['Variant'] = 'tree'
|
|
154
|
+
# conv: active pixels in parallel (<= n_max_pixels), and filters in parallel (<= that conv's filters)
|
|
155
|
+
hls_config['LayerName']['conv1']['PixelParallelFactor'] = n_max_pixels
|
|
156
|
+
hls_config['LayerName']['conv1']['FiltParallelFactor'] = 3
|
|
157
|
+
# pool: active pixels in parallel, and channels in parallel
|
|
158
|
+
hls_config['LayerName']['pool1']['PixelParallelFactor'] = n_max_pixels
|
|
159
|
+
hls_config['LayerName']['pool1']['ChanParallelFactor'] = 3
|
|
160
|
+
# flatten: scatter positions in parallel (<= out_height * out_width; here 28*28 pools to 14*14)
|
|
161
|
+
hls_config['LayerName']['flatten']['ParallelFactor'] = 14 * 14
|
|
174
162
|
|
|
175
163
|
hls_model = hls4ml.converters.convert_from_keras_model(
|
|
176
164
|
model,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
[](https://arxiv.org/abs/2512.06208)
|
|
17
17
|
[](https://pypi.org/project/sparsepixels)
|
|
18
18
|
|
|
19
|
-
SparsePixels is a Keras 3 library to build, train, and deploy sparse convolutional neural networks on FPGAs. In many detectors, especially in high-energy physics experiments, the images are almost empty: only a handful of pixels carry a signal (the hits), yet a standard CNN still spends compute on every pixel. A sparse CNN convolves only over the active pixels, so its cost scales with the number of hits rather than the image size, which is what makes low-latency, real-time inference (
|
|
19
|
+
SparsePixels is a Keras 3 library to build, train, and deploy sparse convolutional neural networks on FPGAs. In many detectors, especially in high-energy physics experiments, the images are almost empty: only a handful of pixels carry a signal (the hits), yet a standard CNN still spends compute on every pixel. A sparse CNN convolves only over the active pixels, so its cost scales with the number of hits rather than the image size, which is what makes low-latency, real-time inference (e.g. in a trigger) feasible on an FPGA. This library builds quantization-aware (with [HGQ2](https://github.com/calad0i/HGQ2) as a backend) sparse CNNs in which the pixel budget and the activity threshold can be learned from data, with a hardware-aware penalty that drives the budget toward the fewest pixels the task tolerates. Trained models convert to FPGA firmware through the [hls4ml](https://github.com/fastmachinelearning/hls4ml) integration, with control over the parallelization of the sparse layers so user can trade latency against resource usage. This training library is designed to mirror our HLS design with bit-exact accuracy.
|
|
20
20
|
|
|
21
21
|
## Installation
|
|
22
22
|
|
|
@@ -26,36 +26,33 @@ With Python >= 3.10:
|
|
|
26
26
|
pip install sparsepixels
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
This includes all necessary backends (tensorflow, keras, HGQ2).
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
import keras
|
|
33
|
-
from keras.layers import Flatten, Activation
|
|
34
|
-
from hgq.layers import QDense
|
|
35
|
-
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
36
|
-
from hgq.quantizer.config import QuantizerConfig
|
|
37
|
-
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
38
|
-
from sparsepixels.utils import (
|
|
39
|
-
active_pixels_vs_threshold, plot_reduced_examples, cosine_lr,
|
|
40
|
-
SparseTrainingMonitor, plot_history, print_quantization, plot_quantization,
|
|
41
|
-
)
|
|
42
|
-
```
|
|
31
|
+
## Getting Started
|
|
43
32
|
|
|
44
33
|
First, study the data to pick a threshold and an initial pixel budget `n`: how many pixels stay
|
|
45
|
-
active as the threshold rises, and what a candidate `(n, threshold)`
|
|
34
|
+
active as the threshold rises, and what a candidate `(n, threshold)` maskes out pixels on a few example images.
|
|
46
35
|
|
|
47
36
|
```python
|
|
37
|
+
from sparsepixels.utils import active_pixels_vs_threshold, plot_reduced_examples
|
|
38
|
+
|
|
48
39
|
active_pixels_vs_threshold(x_train)
|
|
49
|
-
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=
|
|
40
|
+
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=3)
|
|
50
41
|
```
|
|
51
42
|
|
|
52
43
|
Build an example sparse CNN within HGQ2 quantization scopes. A custom input quantizer config with
|
|
53
|
-
higher initial fractional bits (`f0=8`) prevents the default (`f0=2`) from zeroing out sparse signals
|
|
54
|
-
in early training epochs. `InputReduce` keeps the first `n` active pixels (first channel above
|
|
55
|
-
`threshold`)
|
|
56
|
-
|
|
44
|
+
higher initial fractional bits (e.g., `f0=8`) prevents the lower default (`f0=2`) from aggressively zeroing out sparse signals
|
|
45
|
+
in early training epochs. `InputReduce` keeps the first `n` active pixels (based on the first channel above
|
|
46
|
+
`threshold`) in a row-major order.
|
|
47
|
+
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.
|
|
57
48
|
|
|
58
49
|
```python
|
|
50
|
+
import keras
|
|
51
|
+
from hgq.layers import QDense
|
|
52
|
+
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
53
|
+
from hgq.quantizer.config import QuantizerConfig
|
|
54
|
+
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
55
|
+
|
|
59
56
|
iq_conf = QuantizerConfig(place='datalane', q_type='kif', i0=4, f0=8, overflow_mode='WRAP')
|
|
60
57
|
|
|
61
58
|
with (
|
|
@@ -65,54 +62,49 @@ with (
|
|
|
65
62
|
):
|
|
66
63
|
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
67
64
|
|
|
68
|
-
# Sparse input reduction
|
|
69
65
|
x, keep_mask = InputReduce(
|
|
70
|
-
n=30,
|
|
71
|
-
threshold=0.1,
|
|
72
|
-
beta_n=
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
n=30, # initial pixel budget
|
|
67
|
+
threshold=0.1, # initial activity threshold
|
|
68
|
+
beta_n=5e-3, # higher -> drives the pixel budget n smaller
|
|
69
|
+
beta_maskedE=1.0, # higher -> prevents over-masking
|
|
70
|
+
learn_n=True, # trainable pixel budget
|
|
71
|
+
learn_threshold=True, # trainable threshold
|
|
75
72
|
name='input_reduce',
|
|
76
73
|
)(x_in)
|
|
77
|
-
|
|
78
|
-
# Sparse convolution
|
|
79
74
|
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
80
75
|
activation='relu', iq_conf=iq_conf)([x, keep_mask])
|
|
81
|
-
|
|
82
|
-
# Sparse pooling
|
|
83
76
|
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
84
77
|
|
|
85
|
-
x = Flatten(name='flatten')(x)
|
|
78
|
+
x = keras.layers.Flatten(name='flatten')(x)
|
|
86
79
|
x = QDense(10, name='dense1', activation='relu', iq_conf=iq_conf)(x)
|
|
87
|
-
x = Activation('softmax', name='softmax')(x)
|
|
80
|
+
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
88
81
|
|
|
89
82
|
model = keras.Model(x_in, x)
|
|
90
83
|
```
|
|
91
84
|
|
|
92
85
|
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
93
|
-
budget/threshold and the
|
|
94
|
-
hardware cost) to the sparse compute automatically
|
|
95
|
-
only sparse-specific choices are the cosine-decayed learning rate and `restore_best_weights`, which
|
|
96
|
-
keep the learned budget from over-compressing near the end of training.
|
|
86
|
+
budget/threshold, the EBOPS and the masked-intensity penalty each epoch, and it corrects the EBOPS
|
|
87
|
+
(a proxy for the quantized hardware cost) to the sparse compute automatically.
|
|
97
88
|
|
|
98
89
|
```python
|
|
90
|
+
from sparsepixels.utils import SparseTrainingMonitor
|
|
91
|
+
|
|
99
92
|
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max', patience=20, restore_best_weights=True)
|
|
100
93
|
model.compile(
|
|
101
|
-
optimizer=keras.optimizers.Adam(
|
|
94
|
+
optimizer=keras.optimizers.Adam(1e-3),
|
|
102
95
|
loss='categorical_crossentropy', metrics=['accuracy'],
|
|
103
96
|
)
|
|
104
97
|
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
105
98
|
epochs=100, batch_size=128, callbacks=[early_stop, SparseTrainingMonitor()])
|
|
106
99
|
```
|
|
107
100
|
|
|
108
|
-
After training, plot the diagnostics and
|
|
109
|
-
shows the loss breakdown, the learned budget/threshold and the EBOPS in one figure.
|
|
110
|
-
`print_quantization` / `plot_quantization` summarize the per-layer bit-widths. The values to
|
|
111
|
-
deploy are `layer.n_max_pixels` and `layer.threshold` (hls4ml converter will auto-parse these from the model).
|
|
101
|
+
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).
|
|
112
102
|
|
|
113
103
|
```python
|
|
114
|
-
|
|
115
|
-
|
|
104
|
+
from sparsepixels.utils import plot_history, print_quantization, plot_quantization
|
|
105
|
+
|
|
106
|
+
plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
|
|
107
|
+
print_quantization(model) # per-layer bit-width distribution and EBOPS
|
|
116
108
|
plot_quantization(model)
|
|
117
109
|
|
|
118
110
|
ir = model.get_layer('input_reduce')
|
|
@@ -121,7 +113,7 @@ print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
|
121
113
|
|
|
122
114
|
## Converting a trained model to HLS with hls4ml
|
|
123
115
|
|
|
124
|
-
> **Note:** A [PR](https://github.com/fastmachinelearning/hls4ml/pull/1468) adding `sparsepixels` support to the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo has been submitted but is not yet merged. In the meantime you can install hls4ml from the PR branch on this fork to
|
|
116
|
+
> **Note:** A [PR](https://github.com/fastmachinelearning/hls4ml/pull/1468) adding `sparsepixels` support to the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo has been submitted but is not yet merged. In the meantime you can install hls4ml from the PR branch on this fork to use the converter:
|
|
125
117
|
>
|
|
126
118
|
> ```bash
|
|
127
119
|
> pip install "git+https://github.com/hftsoi/hls4ml.git@sparsepixels"
|
|
@@ -136,22 +128,18 @@ import hls4ml
|
|
|
136
128
|
hls_config = hls4ml.utils.config_from_keras_model(model, granularity='name')
|
|
137
129
|
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' # "#pragma HLS DATAFLOW"
|
|
138
130
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
'flatten': {'ParallelFactor': 14 * 14}, # this model pools 28*28 -> 14*14
|
|
152
|
-
}
|
|
153
|
-
for name, cfg in knobs.items():
|
|
154
|
-
hls_config['LayerName'].setdefault(name, {}).update(cfg)
|
|
131
|
+
n_max_pixels = model.get_layer('input_reduce').n_max_pixels
|
|
132
|
+
|
|
133
|
+
# input reduce: 'tree' (default, lowest latency) or 'stream' (sequential, fewer resources)
|
|
134
|
+
hls_config['LayerName']['input_reduce']['Variant'] = 'tree'
|
|
135
|
+
# conv: active pixels in parallel (<= n_max_pixels), and filters in parallel (<= that conv's filters)
|
|
136
|
+
hls_config['LayerName']['conv1']['PixelParallelFactor'] = n_max_pixels
|
|
137
|
+
hls_config['LayerName']['conv1']['FiltParallelFactor'] = 3
|
|
138
|
+
# pool: active pixels in parallel, and channels in parallel
|
|
139
|
+
hls_config['LayerName']['pool1']['PixelParallelFactor'] = n_max_pixels
|
|
140
|
+
hls_config['LayerName']['pool1']['ChanParallelFactor'] = 3
|
|
141
|
+
# flatten: scatter positions in parallel (<= out_height * out_width; here 28*28 pools to 14*14)
|
|
142
|
+
hls_config['LayerName']['flatten']['ParallelFactor'] = 14 * 14
|
|
155
143
|
|
|
156
144
|
hls_model = hls4ml.converters.convert_from_keras_model(
|
|
157
145
|
model,
|
|
@@ -1149,6 +1149,103 @@ def preview_patches_with_pooling(
|
|
|
1149
1149
|
plt.show()
|
|
1150
1150
|
|
|
1151
1151
|
|
|
1152
|
+
def plot_layer_outputs(model, x_input, sample_idx=0, layer_names=None,
|
|
1153
|
+
cmap='viridis', figsize_per_ch=3):
|
|
1154
|
+
"""Plot channel-wise feature maps for intermediate layers of a sparse or dense CNN.
|
|
1155
|
+
|
|
1156
|
+
Parameters
|
|
1157
|
+
----------
|
|
1158
|
+
model : keras.Model
|
|
1159
|
+
The model (sparse or dense). Sparse models may have layers with
|
|
1160
|
+
tuple outputs (features, mask).
|
|
1161
|
+
x_input : np.ndarray
|
|
1162
|
+
Full input dataset (batch, H, W, C).
|
|
1163
|
+
sample_idx : int
|
|
1164
|
+
Index of the sample in *x_input* to visualise.
|
|
1165
|
+
layer_names : list[str] or None
|
|
1166
|
+
Layer names to plot. If *None*, auto-discovers layers whose names
|
|
1167
|
+
start with common prefixes (x_in, input_reduce, conv, pool, dense).
|
|
1168
|
+
cmap : str
|
|
1169
|
+
Matplotlib colormap name.
|
|
1170
|
+
figsize_per_ch : float
|
|
1171
|
+
Width of each channel subplot in inches.
|
|
1172
|
+
"""
|
|
1173
|
+
import keras
|
|
1174
|
+
|
|
1175
|
+
if layer_names is None:
|
|
1176
|
+
prefixes = ('x_in', 'input_reduce', 'conv', 'pool', 'dense')
|
|
1177
|
+
layer_names = [l.name for l in model.layers
|
|
1178
|
+
if any(l.name.startswith(p) for p in prefixes)]
|
|
1179
|
+
|
|
1180
|
+
plot_tensors = []
|
|
1181
|
+
plot_names = []
|
|
1182
|
+
for name in layer_names:
|
|
1183
|
+
layer = model.get_layer(name)
|
|
1184
|
+
output = layer.output
|
|
1185
|
+
if isinstance(output, (list, tuple)):
|
|
1186
|
+
plot_tensors.append(output[0])
|
|
1187
|
+
plot_names.append(f'{name} (features)')
|
|
1188
|
+
plot_tensors.append(output[1])
|
|
1189
|
+
plot_names.append(f'{name} (mask)')
|
|
1190
|
+
else:
|
|
1191
|
+
plot_tensors.append(output)
|
|
1192
|
+
plot_names.append(name)
|
|
1193
|
+
|
|
1194
|
+
probe = keras.models.Model(inputs=model.input, outputs=plot_tensors)
|
|
1195
|
+
preds = probe.predict(x_input[sample_idx:sample_idx + 1], verbose=0)
|
|
1196
|
+
|
|
1197
|
+
if not isinstance(preds, list):
|
|
1198
|
+
preds = [preds]
|
|
1199
|
+
|
|
1200
|
+
i = 0
|
|
1201
|
+
while i < len(plot_names):
|
|
1202
|
+
name = plot_names[i]
|
|
1203
|
+
|
|
1204
|
+
if '(features)' in name and i + 1 < len(plot_names) and '(mask)' in plot_names[i + 1]:
|
|
1205
|
+
arr = preds[i][0]
|
|
1206
|
+
if arr.ndim == 3:
|
|
1207
|
+
h, w, c = arr.shape
|
|
1208
|
+
fig, axes = plt.subplots(1, c, figsize=(c * figsize_per_ch, figsize_per_ch))
|
|
1209
|
+
fig.suptitle(name.replace(' (features)', ''), fontsize=13)
|
|
1210
|
+
if c == 1:
|
|
1211
|
+
axes = [axes]
|
|
1212
|
+
for ch in range(c):
|
|
1213
|
+
axes[ch].imshow(arr[..., ch], cmap=cmap)
|
|
1214
|
+
axes[ch].set_title(f'ch{ch}')
|
|
1215
|
+
plt.tight_layout()
|
|
1216
|
+
plt.show()
|
|
1217
|
+
i += 2
|
|
1218
|
+
continue
|
|
1219
|
+
|
|
1220
|
+
arr = preds[i][0]
|
|
1221
|
+
if arr.ndim == 1:
|
|
1222
|
+
fig, ax = plt.subplots(1, 1, figsize=(max(len(arr) * 0.3, 4), figsize_per_ch))
|
|
1223
|
+
fig.suptitle(name, fontsize=13)
|
|
1224
|
+
ax.bar(range(len(arr)), arr)
|
|
1225
|
+
ax.set_xlabel('unit')
|
|
1226
|
+
plt.tight_layout()
|
|
1227
|
+
plt.show()
|
|
1228
|
+
elif arr.ndim == 2:
|
|
1229
|
+
fig, ax = plt.subplots(1, 1, figsize=(figsize_per_ch, figsize_per_ch))
|
|
1230
|
+
fig.suptitle(name, fontsize=13)
|
|
1231
|
+
ax.imshow(arr, cmap=cmap)
|
|
1232
|
+
ax.set_title('ch0')
|
|
1233
|
+
plt.tight_layout()
|
|
1234
|
+
plt.show()
|
|
1235
|
+
elif arr.ndim == 3:
|
|
1236
|
+
h, w, c = arr.shape
|
|
1237
|
+
fig, axes = plt.subplots(1, c, figsize=(c * figsize_per_ch, figsize_per_ch))
|
|
1238
|
+
fig.suptitle(name, fontsize=13)
|
|
1239
|
+
if c == 1:
|
|
1240
|
+
axes = [axes]
|
|
1241
|
+
for ch in range(c):
|
|
1242
|
+
axes[ch].imshow(arr[..., ch], cmap=cmap)
|
|
1243
|
+
axes[ch].set_title(f'ch{ch}')
|
|
1244
|
+
plt.tight_layout()
|
|
1245
|
+
plt.show()
|
|
1246
|
+
i += 1
|
|
1247
|
+
|
|
1248
|
+
|
|
1152
1249
|
def _fmt_bits(bits_tensor):
|
|
1153
1250
|
b = np.array(bits_tensor).flatten()
|
|
1154
1251
|
if b.size == 1:
|
|
@@ -32,13 +32,18 @@ class InputReduce(keras.layers.Layer):
|
|
|
32
32
|
plain, non-learnable selection). The selection is always exact -- the learnable versions only
|
|
33
33
|
shape the gradient, so the layer behaves identically at inference and stays deployable. When n is
|
|
34
34
|
learned, a penalty of weight beta_n nudges it smaller, trading a little accuracy for lower FPGA
|
|
35
|
-
latency and resources; it starts at n and
|
|
36
|
-
|
|
35
|
+
latency and resources; it starts at n and can range over [1, H*W]. An optional penalty of weight
|
|
36
|
+
beta_maskedE discourages masking pixel intensity, giving the threshold (and n) a restoring force so a
|
|
37
|
+
trainable threshold settles near the noise floor instead of over-masking signal. After training,
|
|
38
|
+
read the values to deploy from the n_max_pixels and threshold properties.
|
|
37
39
|
|
|
38
40
|
Args:
|
|
39
41
|
n: initial pixel budget, and the fixed budget when learn_n is False.
|
|
40
42
|
threshold: initial activity threshold on the first channel, fixed when learn_threshold is False.
|
|
41
43
|
beta_n: weight of the budget penalty added to the loss (0 disables it).
|
|
44
|
+
beta_maskedE: weight of the masked-intensity penalty (0 disables it). Penalizes the fraction of pixel
|
|
45
|
+
intensity that gets masked, so masking bright (signal) pixels is costly while masking dim
|
|
46
|
+
(noise) pixels is cheap -- an adaptive, non-vanishing restoring force against over-masking.
|
|
42
47
|
learn_n: make the pixel budget trainable.
|
|
43
48
|
learn_threshold: make the threshold trainable.
|
|
44
49
|
tau_threshold: softness of the threshold surrogate used to obtain gradients.
|
|
@@ -49,7 +54,8 @@ class InputReduce(keras.layers.Layer):
|
|
|
49
54
|
self,
|
|
50
55
|
n=30,
|
|
51
56
|
threshold=0.0,
|
|
52
|
-
beta_n=
|
|
57
|
+
beta_n=5e-3,
|
|
58
|
+
beta_maskedE=1.0,
|
|
53
59
|
learn_n=True,
|
|
54
60
|
learn_threshold=True,
|
|
55
61
|
tau_threshold=0.05,
|
|
@@ -60,6 +66,7 @@ class InputReduce(keras.layers.Layer):
|
|
|
60
66
|
self.n_init = int(n)
|
|
61
67
|
self.threshold_init = float(threshold)
|
|
62
68
|
self.beta_n = float(beta_n)
|
|
69
|
+
self.beta_maskedE = float(beta_maskedE)
|
|
63
70
|
self.learn_n = learn_n
|
|
64
71
|
self.learn_threshold = learn_threshold
|
|
65
72
|
self.tau_threshold = float(tau_threshold)
|
|
@@ -76,13 +83,24 @@ class InputReduce(keras.layers.Layer):
|
|
|
76
83
|
)
|
|
77
84
|
if self.learn_n:
|
|
78
85
|
# Parametrize the budget as a fraction of its initial value (n = n_init * n_frac) so it
|
|
79
|
-
# moves at a useful rate under Adam; clip to [1,
|
|
86
|
+
# moves at a useful rate under Adam; clip to [1, H*W] so it can shrink to a single pixel
|
|
87
|
+
# or grow up to keeping every pixel.
|
|
88
|
+
if input_shape[1] and input_shape[2]:
|
|
89
|
+
hi = (input_shape[1] * input_shape[2]) / self.n_init
|
|
90
|
+
else:
|
|
91
|
+
hi = 4.0
|
|
80
92
|
self.n_frac = self.add_weight(
|
|
81
93
|
name="n_frac",
|
|
82
94
|
shape=(),
|
|
83
95
|
initializer=keras.initializers.Constant(1.0),
|
|
84
96
|
trainable=True,
|
|
85
|
-
constraint=_ClipToRange(1.0 / self.n_init,
|
|
97
|
+
constraint=_ClipToRange(1.0 / self.n_init, max(1.0, hi)),
|
|
98
|
+
)
|
|
99
|
+
if self.beta_maskedE > 0:
|
|
100
|
+
# Running masked-intensity fraction, tracked (not trained) so SparseTrainingMonitor can
|
|
101
|
+
# report the penalty; refreshed each call.
|
|
102
|
+
self._masked_intensity = self.add_weight(
|
|
103
|
+
name="masked_intensity", shape=(), initializer="zeros", trainable=False
|
|
86
104
|
)
|
|
87
105
|
super().build(input_shape)
|
|
88
106
|
|
|
@@ -108,6 +126,15 @@ class InputReduce(keras.layers.Layer):
|
|
|
108
126
|
rank_soft = ops.cumsum(active_soft, axis=1)
|
|
109
127
|
keep_soft = active_soft * ops.sigmoid((n - rank_soft) / self.tau_n)
|
|
110
128
|
keep_flat = keep_soft + ops.stop_gradient(keep_hard - keep_soft)
|
|
129
|
+
|
|
130
|
+
if self.beta_maskedE > 0:
|
|
131
|
+
# Fraction of total pixel intensity that gets masked, added straight to the loss (not
|
|
132
|
+
# through the classifier) so its gradient does not vanish for the masked pixels.
|
|
133
|
+
# Masking bright pixels costs more than masking dim ones, so this pushes the threshold
|
|
134
|
+
# back down (and n up) before it eats into signal -- an adaptive anti-over-masking term.
|
|
135
|
+
masked_frac = ops.mean(ops.sum(score * (1.0 - keep_soft), axis=1) / (ops.sum(score, axis=1) + 1e-6))
|
|
136
|
+
self.add_loss(self.beta_maskedE * masked_frac)
|
|
137
|
+
self._masked_intensity.assign(masked_frac)
|
|
111
138
|
else:
|
|
112
139
|
keep_flat = keep_hard
|
|
113
140
|
|
|
@@ -140,6 +167,7 @@ class InputReduce(keras.layers.Layer):
|
|
|
140
167
|
"n": self.n_init,
|
|
141
168
|
"threshold": self.threshold_init,
|
|
142
169
|
"beta_n": self.beta_n,
|
|
170
|
+
"beta_maskedE": self.beta_maskedE,
|
|
143
171
|
"learn_n": self.learn_n,
|
|
144
172
|
"learn_threshold": self.learn_threshold,
|
|
145
173
|
"tau_threshold": self.tau_threshold,
|
|
@@ -9,7 +9,6 @@ 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 keras
|
|
13
12
|
import matplotlib.pyplot as plt
|
|
14
13
|
import numpy as np
|
|
15
14
|
from keras import ops
|
|
@@ -116,6 +115,20 @@ def _budget_reg_loss(model):
|
|
|
116
115
|
return total
|
|
117
116
|
|
|
118
117
|
|
|
118
|
+
def _masked_intensity_reg_loss(model):
|
|
119
|
+
"""Sum beta_maskedE * masked_intensity over the InputReduce layers using the masked-intensity penalty."""
|
|
120
|
+
total = 0.0
|
|
121
|
+
for ir in _input_reduce_layers(model):
|
|
122
|
+
beta_maskedE = getattr(ir, "beta_maskedE", 0.0)
|
|
123
|
+
mi = getattr(ir, "_masked_intensity", None)
|
|
124
|
+
if beta_maskedE > 0 and mi is not None:
|
|
125
|
+
try:
|
|
126
|
+
total += beta_maskedE * float(ops.convert_to_numpy(mi))
|
|
127
|
+
except Exception:
|
|
128
|
+
continue
|
|
129
|
+
return total
|
|
130
|
+
|
|
131
|
+
|
|
119
132
|
def _fmt_bits(bits_tensor):
|
|
120
133
|
b = np.array(bits_tensor).flatten()
|
|
121
134
|
if b.size == 1:
|
|
@@ -161,8 +174,10 @@ class SparseTrainingMonitor(Callback):
|
|
|
161
174
|
- ebops: total EBOPS over the quantized layers, a proxy for the quantized hardware cost. This
|
|
162
175
|
monitor applies set_sparse_ebops_factor at the start of training, so the EBOPS (and its
|
|
163
176
|
regularizer) reflect the sparse (active-pixel) compute without any manual call.
|
|
164
|
-
- loss_task, loss_ebops, loss_n: the training loss split into the task loss, the
|
|
165
|
-
the pixel-budget penalty
|
|
177
|
+
- loss_task, loss_ebops, loss_n, loss_maskedE: the training loss split into the task loss, the
|
|
178
|
+
EBOPS penalty, the pixel-budget penalty and the masked-intensity penalty (the last present only
|
|
179
|
+
when its weight beta_maskedE is nonzero), each already scaled by its weight so the parts add up to the
|
|
180
|
+
loss.
|
|
166
181
|
"""
|
|
167
182
|
|
|
168
183
|
def on_train_begin(self, logs=None):
|
|
@@ -185,14 +200,18 @@ class SparseTrainingMonitor(Callback):
|
|
|
185
200
|
reg_ebops = _ebops_reg_loss(self.model) if found else 0.0
|
|
186
201
|
has_budget = any(getattr(ir, "learn_n", False) for ir in irs)
|
|
187
202
|
reg_n = _budget_reg_loss(self.model) if has_budget else 0.0
|
|
203
|
+
has_maskedE = any(getattr(ir, "beta_maskedE", 0.0) > 0 for ir in irs)
|
|
204
|
+
reg_maskedE = _masked_intensity_reg_loss(self.model) if has_maskedE else 0.0
|
|
188
205
|
if found:
|
|
189
206
|
logs["ebops"] = ebops
|
|
190
207
|
logs["loss_ebops"] = reg_ebops
|
|
191
208
|
if has_budget:
|
|
192
209
|
logs["loss_n"] = reg_n
|
|
210
|
+
if has_maskedE:
|
|
211
|
+
logs["loss_maskedE"] = reg_maskedE
|
|
193
212
|
# Task loss is the total minus the (train-only) penalties, so the parts add up to the loss.
|
|
194
213
|
if "loss" in logs:
|
|
195
|
-
logs["loss_task"] = logs["loss"] - reg_ebops - reg_n
|
|
214
|
+
logs["loss_task"] = logs["loss"] - reg_ebops - reg_n - reg_maskedE
|
|
196
215
|
|
|
197
216
|
|
|
198
217
|
def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
@@ -220,7 +239,7 @@ def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
|
220
239
|
|
|
221
240
|
def _is_single(k):
|
|
222
241
|
return (
|
|
223
|
-
k in ("ebops", "loss_task", "loss_ebops", "loss_n")
|
|
242
|
+
k in ("ebops", "loss_task", "loss_ebops", "loss_n", "loss_maskedE")
|
|
224
243
|
or k.startswith("n_max_pixels")
|
|
225
244
|
or k.startswith("threshold")
|
|
226
245
|
)
|
|
@@ -230,7 +249,7 @@ def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
|
230
249
|
metric_keys = [k for k in h if not k.startswith("val_") and k not in skip]
|
|
231
250
|
m_order = ["loss"]
|
|
232
251
|
metric_keys = [k for k in m_order if k in metric_keys] + [k for k in metric_keys if k not in m_order]
|
|
233
|
-
s_order = ["loss_task", "loss_ebops", "loss_n", "n_max_pixels", "threshold", "ebops"]
|
|
252
|
+
s_order = ["loss_task", "loss_ebops", "loss_n", "loss_maskedE", "n_max_pixels", "threshold", "ebops"]
|
|
234
253
|
single_keys = [k for k in s_order if k in single_keys] + [k for k in single_keys if k not in s_order]
|
|
235
254
|
|
|
236
255
|
panels = metric_keys + single_keys
|
|
@@ -254,7 +273,7 @@ def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
|
254
273
|
show_legend = True
|
|
255
274
|
else:
|
|
256
275
|
vals = list(h[key])
|
|
257
|
-
color = "tab:red" if key in ("loss_task", "loss_ebops", "loss_n") else "tab:green"
|
|
276
|
+
color = "tab:red" if key in ("loss_task", "loss_ebops", "loss_n", "loss_maskedE") else "tab:green"
|
|
258
277
|
ax.plot(ep, vals, marker=".", color=color)
|
|
259
278
|
# annotate first and last epoch (above the point) so the endpoints are easy to read
|
|
260
279
|
for xi, yi in ((1, vals[0]), (len(vals), vals[-1])):
|
|
@@ -292,26 +311,6 @@ def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
|
292
311
|
plt.show()
|
|
293
312
|
|
|
294
313
|
|
|
295
|
-
def cosine_lr(initial_learning_rate, epochs, steps_per_epoch, alpha=0.0):
|
|
296
|
-
"""Cosine-decay learning-rate schedule spanning the whole training run.
|
|
297
|
-
|
|
298
|
-
A decaying rate lets the learnable budget and threshold move quickly early and then settle late,
|
|
299
|
-
instead of drifting the whole time (which is what pushes the budget to over-compress near the
|
|
300
|
-
end). Pass the result as the optimizer's learning_rate, and pair it with an EarlyStopping that
|
|
301
|
-
monitors val_accuracy with restore_best_weights so the best-validation values are the ones you
|
|
302
|
-
deploy.
|
|
303
|
-
|
|
304
|
-
Args:
|
|
305
|
-
initial_learning_rate: starting learning rate, e.g. 1e-3.
|
|
306
|
-
epochs: number of training epochs.
|
|
307
|
-
steps_per_epoch: batches per epoch, i.e. ceil(len(x_train) / batch_size).
|
|
308
|
-
alpha: final learning rate as a fraction of the initial (0 decays to about 0).
|
|
309
|
-
"""
|
|
310
|
-
return keras.optimizers.schedules.CosineDecay(
|
|
311
|
-
initial_learning_rate, decay_steps=int(epochs * steps_per_epoch), alpha=alpha
|
|
312
|
-
)
|
|
313
|
-
|
|
314
|
-
|
|
315
314
|
def active_pixels_vs_threshold(x, thresholds=None, channel=0, percentiles=(25, 50, 75), plot=True, ax=None):
|
|
316
315
|
"""Study how the number of active pixels per image changes with the threshold.
|
|
317
316
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sparsepixels
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
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
|
|
@@ -35,7 +35,7 @@ Dynamic: license-file
|
|
|
35
35
|
[](https://arxiv.org/abs/2512.06208)
|
|
36
36
|
[](https://pypi.org/project/sparsepixels)
|
|
37
37
|
|
|
38
|
-
SparsePixels is a Keras 3 library to build, train, and deploy sparse convolutional neural networks on FPGAs. In many detectors, especially in high-energy physics experiments, the images are almost empty: only a handful of pixels carry a signal (the hits), yet a standard CNN still spends compute on every pixel. A sparse CNN convolves only over the active pixels, so its cost scales with the number of hits rather than the image size, which is what makes low-latency, real-time inference (
|
|
38
|
+
SparsePixels is a Keras 3 library to build, train, and deploy sparse convolutional neural networks on FPGAs. In many detectors, especially in high-energy physics experiments, the images are almost empty: only a handful of pixels carry a signal (the hits), yet a standard CNN still spends compute on every pixel. A sparse CNN convolves only over the active pixels, so its cost scales with the number of hits rather than the image size, which is what makes low-latency, real-time inference (e.g. in a trigger) feasible on an FPGA. This library builds quantization-aware (with [HGQ2](https://github.com/calad0i/HGQ2) as a backend) sparse CNNs in which the pixel budget and the activity threshold can be learned from data, with a hardware-aware penalty that drives the budget toward the fewest pixels the task tolerates. Trained models convert to FPGA firmware through the [hls4ml](https://github.com/fastmachinelearning/hls4ml) integration, with control over the parallelization of the sparse layers so user can trade latency against resource usage. This training library is designed to mirror our HLS design with bit-exact accuracy.
|
|
39
39
|
|
|
40
40
|
## Installation
|
|
41
41
|
|
|
@@ -45,36 +45,33 @@ With Python >= 3.10:
|
|
|
45
45
|
pip install sparsepixels
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
This includes all necessary backends (tensorflow, keras, HGQ2).
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
import keras
|
|
52
|
-
from keras.layers import Flatten, Activation
|
|
53
|
-
from hgq.layers import QDense
|
|
54
|
-
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
55
|
-
from hgq.quantizer.config import QuantizerConfig
|
|
56
|
-
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
57
|
-
from sparsepixels.utils import (
|
|
58
|
-
active_pixels_vs_threshold, plot_reduced_examples, cosine_lr,
|
|
59
|
-
SparseTrainingMonitor, plot_history, print_quantization, plot_quantization,
|
|
60
|
-
)
|
|
61
|
-
```
|
|
50
|
+
## Getting Started
|
|
62
51
|
|
|
63
52
|
First, study the data to pick a threshold and an initial pixel budget `n`: how many pixels stay
|
|
64
|
-
active as the threshold rises, and what a candidate `(n, threshold)`
|
|
53
|
+
active as the threshold rises, and what a candidate `(n, threshold)` maskes out pixels on a few example images.
|
|
65
54
|
|
|
66
55
|
```python
|
|
56
|
+
from sparsepixels.utils import active_pixels_vs_threshold, plot_reduced_examples
|
|
57
|
+
|
|
67
58
|
active_pixels_vs_threshold(x_train)
|
|
68
|
-
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=
|
|
59
|
+
plot_reduced_examples(x_train, n=20, threshold=0.1, n_examples=3)
|
|
69
60
|
```
|
|
70
61
|
|
|
71
62
|
Build an example sparse CNN within HGQ2 quantization scopes. A custom input quantizer config with
|
|
72
|
-
higher initial fractional bits (`f0=8`) prevents the default (`f0=2`) from zeroing out sparse signals
|
|
73
|
-
in early training epochs. `InputReduce` keeps the first `n` active pixels (first channel above
|
|
74
|
-
`threshold`)
|
|
75
|
-
|
|
63
|
+
higher initial fractional bits (e.g., `f0=8`) prevents the lower default (`f0=2`) from aggressively zeroing out sparse signals
|
|
64
|
+
in early training epochs. `InputReduce` keeps the first `n` active pixels (based on the first channel above
|
|
65
|
+
`threshold`) in a row-major order.
|
|
66
|
+
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.
|
|
76
67
|
|
|
77
68
|
```python
|
|
69
|
+
import keras
|
|
70
|
+
from hgq.layers import QDense
|
|
71
|
+
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
72
|
+
from hgq.quantizer.config import QuantizerConfig
|
|
73
|
+
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')
|
|
79
76
|
|
|
80
77
|
with (
|
|
@@ -84,54 +81,49 @@ with (
|
|
|
84
81
|
):
|
|
85
82
|
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
86
83
|
|
|
87
|
-
# Sparse input reduction
|
|
88
84
|
x, keep_mask = InputReduce(
|
|
89
|
-
n=30,
|
|
90
|
-
threshold=0.1,
|
|
91
|
-
beta_n=
|
|
92
|
-
|
|
93
|
-
|
|
85
|
+
n=30, # initial pixel budget
|
|
86
|
+
threshold=0.1, # initial activity threshold
|
|
87
|
+
beta_n=5e-3, # higher -> drives the pixel budget n smaller
|
|
88
|
+
beta_maskedE=1.0, # higher -> prevents over-masking
|
|
89
|
+
learn_n=True, # trainable pixel budget
|
|
90
|
+
learn_threshold=True, # trainable threshold
|
|
94
91
|
name='input_reduce',
|
|
95
92
|
)(x_in)
|
|
96
|
-
|
|
97
|
-
# Sparse convolution
|
|
98
93
|
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
99
94
|
activation='relu', iq_conf=iq_conf)([x, keep_mask])
|
|
100
|
-
|
|
101
|
-
# Sparse pooling
|
|
102
95
|
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
103
96
|
|
|
104
|
-
x = Flatten(name='flatten')(x)
|
|
97
|
+
x = keras.layers.Flatten(name='flatten')(x)
|
|
105
98
|
x = QDense(10, name='dense1', activation='relu', iq_conf=iq_conf)(x)
|
|
106
|
-
x = Activation('softmax', name='softmax')(x)
|
|
99
|
+
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
107
100
|
|
|
108
101
|
model = keras.Model(x_in, x)
|
|
109
102
|
```
|
|
110
103
|
|
|
111
104
|
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
112
|
-
budget/threshold and the
|
|
113
|
-
hardware cost) to the sparse compute automatically
|
|
114
|
-
only sparse-specific choices are the cosine-decayed learning rate and `restore_best_weights`, which
|
|
115
|
-
keep the learned budget from over-compressing near the end of training.
|
|
105
|
+
budget/threshold, the EBOPS and the masked-intensity penalty each epoch, and it corrects the EBOPS
|
|
106
|
+
(a proxy for the quantized hardware cost) to the sparse compute automatically.
|
|
116
107
|
|
|
117
108
|
```python
|
|
109
|
+
from sparsepixels.utils import SparseTrainingMonitor
|
|
110
|
+
|
|
118
111
|
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max', patience=20, restore_best_weights=True)
|
|
119
112
|
model.compile(
|
|
120
|
-
optimizer=keras.optimizers.Adam(
|
|
113
|
+
optimizer=keras.optimizers.Adam(1e-3),
|
|
121
114
|
loss='categorical_crossentropy', metrics=['accuracy'],
|
|
122
115
|
)
|
|
123
116
|
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
124
117
|
epochs=100, batch_size=128, callbacks=[early_stop, SparseTrainingMonitor()])
|
|
125
118
|
```
|
|
126
119
|
|
|
127
|
-
After training, plot the diagnostics and
|
|
128
|
-
shows the loss breakdown, the learned budget/threshold and the EBOPS in one figure.
|
|
129
|
-
`print_quantization` / `plot_quantization` summarize the per-layer bit-widths. The values to
|
|
130
|
-
deploy are `layer.n_max_pixels` and `layer.threshold` (hls4ml converter will auto-parse these from the model).
|
|
120
|
+
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).
|
|
131
121
|
|
|
132
122
|
```python
|
|
133
|
-
|
|
134
|
-
|
|
123
|
+
from sparsepixels.utils import plot_history, print_quantization, plot_quantization
|
|
124
|
+
|
|
125
|
+
plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
|
|
126
|
+
print_quantization(model) # per-layer bit-width distribution and EBOPS
|
|
135
127
|
plot_quantization(model)
|
|
136
128
|
|
|
137
129
|
ir = model.get_layer('input_reduce')
|
|
@@ -140,7 +132,7 @@ print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
|
140
132
|
|
|
141
133
|
## Converting a trained model to HLS with hls4ml
|
|
142
134
|
|
|
143
|
-
> **Note:** A [PR](https://github.com/fastmachinelearning/hls4ml/pull/1468) adding `sparsepixels` support to the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo has been submitted but is not yet merged. In the meantime you can install hls4ml from the PR branch on this fork to
|
|
135
|
+
> **Note:** A [PR](https://github.com/fastmachinelearning/hls4ml/pull/1468) adding `sparsepixels` support to the official [hls4ml](https://github.com/fastmachinelearning/hls4ml) repo has been submitted but is not yet merged. In the meantime you can install hls4ml from the PR branch on this fork to use the converter:
|
|
144
136
|
>
|
|
145
137
|
> ```bash
|
|
146
138
|
> pip install "git+https://github.com/hftsoi/hls4ml.git@sparsepixels"
|
|
@@ -155,22 +147,18 @@ import hls4ml
|
|
|
155
147
|
hls_config = hls4ml.utils.config_from_keras_model(model, granularity='name')
|
|
156
148
|
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' # "#pragma HLS DATAFLOW"
|
|
157
149
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
#
|
|
161
|
-
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
'flatten': {'ParallelFactor': 14 * 14}, # this model pools 28*28 -> 14*14
|
|
171
|
-
}
|
|
172
|
-
for name, cfg in knobs.items():
|
|
173
|
-
hls_config['LayerName'].setdefault(name, {}).update(cfg)
|
|
150
|
+
n_max_pixels = model.get_layer('input_reduce').n_max_pixels
|
|
151
|
+
|
|
152
|
+
# input reduce: 'tree' (default, lowest latency) or 'stream' (sequential, fewer resources)
|
|
153
|
+
hls_config['LayerName']['input_reduce']['Variant'] = 'tree'
|
|
154
|
+
# conv: active pixels in parallel (<= n_max_pixels), and filters in parallel (<= that conv's filters)
|
|
155
|
+
hls_config['LayerName']['conv1']['PixelParallelFactor'] = n_max_pixels
|
|
156
|
+
hls_config['LayerName']['conv1']['FiltParallelFactor'] = 3
|
|
157
|
+
# pool: active pixels in parallel, and channels in parallel
|
|
158
|
+
hls_config['LayerName']['pool1']['PixelParallelFactor'] = n_max_pixels
|
|
159
|
+
hls_config['LayerName']['pool1']['ChanParallelFactor'] = 3
|
|
160
|
+
# flatten: scatter positions in parallel (<= out_height * out_width; here 28*28 pools to 14*14)
|
|
161
|
+
hls_config['LayerName']['flatten']['ParallelFactor'] = 14 * 14
|
|
174
162
|
|
|
175
163
|
hls_model = hls4ml.converters.convert_from_keras_model(
|
|
176
164
|
model,
|
|
@@ -11,7 +11,7 @@ from hgq.quantizer.config import QuantizerConfig
|
|
|
11
11
|
from keras.layers import Activation, AveragePooling2D, Flatten
|
|
12
12
|
|
|
13
13
|
from sparsepixels.layers import AveragePooling2DSparse, InputReduce, QConv2DSparse
|
|
14
|
-
from sparsepixels.utils import SparseTrainingMonitor,
|
|
14
|
+
from sparsepixels.utils import SparseTrainingMonitor, plot_history, set_sparse_ebops_factor
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def build_cnn(is_sparse, n=20, threshold=0.0, beta_n=1e-4, learn_n=True, learn_threshold=True):
|
|
@@ -74,20 +74,19 @@ def test_sparse_cnn_fixed_mode():
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def test_sparse_cnn_trains_with_monitor():
|
|
77
|
-
# exercise the learnable path,
|
|
77
|
+
# exercise the learnable path, EBOPS factor, monitor and plot on a tiny synthetic set
|
|
78
78
|
rng = np.random.default_rng(0)
|
|
79
79
|
x = rng.random((64, 32, 32, 1)).astype('float32')
|
|
80
80
|
y = keras.utils.to_categorical(np.arange(64) % 10, 10)
|
|
81
81
|
|
|
82
82
|
m = build_cnn(is_sparse=True, n=20, threshold=0.3, beta_n=1e-3)
|
|
83
|
-
steps = int(np.ceil(len(x) / 32))
|
|
84
83
|
m.compile(
|
|
85
|
-
optimizer=keras.optimizers.Adam(
|
|
84
|
+
optimizer=keras.optimizers.Adam(1e-3),
|
|
86
85
|
loss='categorical_crossentropy',
|
|
87
86
|
metrics=['accuracy'],
|
|
88
87
|
)
|
|
89
88
|
history = m.fit(x, y, epochs=2, batch_size=32, verbose=0, callbacks=[SparseTrainingMonitor()])
|
|
90
89
|
|
|
91
|
-
for key in ('loss', 'loss_task', 'loss_ebops', 'loss_n', 'n_max_pixels', 'threshold', 'ebops'):
|
|
90
|
+
for key in ('loss', 'loss_task', 'loss_ebops', 'loss_n', 'loss_maskedE', 'n_max_pixels', 'threshold', 'ebops'):
|
|
92
91
|
assert key in history.history
|
|
93
92
|
plot_history(history) # must run headless without error
|
|
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
|