sparsepixels 0.3.0__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.0/sparsepixels.egg-info → sparsepixels-0.3.2}/PKG-INFO +57 -52
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/README.md +56 -51
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/notebook/utils.py +97 -0
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/setup.cfg +1 -1
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/sparsepixels/layers.py +33 -5
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/sparsepixels/utils.py +39 -32
- {sparsepixels-0.3.0 → sparsepixels-0.3.2/sparsepixels.egg-info}/PKG-INFO +57 -52
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/sparsepixels.egg-info/top_level.txt +0 -1
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/tests/test_model.py +4 -5
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/LICENSE +0 -0
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/pyproject.toml +0 -0
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/setup.py +0 -0
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/sparsepixels/__init__.py +0 -0
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/sparsepixels/img/logo.png +0 -0
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/sparsepixels.egg-info/SOURCES.txt +0 -0
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/sparsepixels.egg-info/dependency_links.txt +0 -0
- {sparsepixels-0.3.0 → sparsepixels-0.3.2}/sparsepixels.egg-info/requires.txt +0 -0
- {sparsepixels-0.3.0 → 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,40 +45,33 @@ With Python >= 3.10:
|
|
|
45
45
|
pip install sparsepixels
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
Import the sparse layers, the quantization library (HGQ2), and the training utilities:
|
|
48
|
+
This includes all necessary backends (tensorflow, keras, HGQ2).
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
import keras
|
|
54
|
-
from keras.layers import Flatten, Activation
|
|
55
|
-
from hgq.layers import QConv2D, QDense
|
|
56
|
-
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
57
|
-
from hgq.quantizer.config import QuantizerConfig
|
|
58
|
-
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
59
|
-
from sparsepixels.utils import (
|
|
60
|
-
active_pixels_vs_threshold, plot_reduced_examples,
|
|
61
|
-
set_sparse_ebops_factor, cosine_lr,
|
|
62
|
-
SparseTrainingMonitor, plot_history,
|
|
63
|
-
print_quantization, plot_quantization,
|
|
64
|
-
)
|
|
65
|
-
```
|
|
50
|
+
## Getting Started
|
|
66
51
|
|
|
67
52
|
First, study the data to pick a threshold and an initial pixel budget `n`: how many pixels stay
|
|
68
|
-
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.
|
|
69
54
|
|
|
70
55
|
```python
|
|
56
|
+
from sparsepixels.utils import active_pixels_vs_threshold, plot_reduced_examples
|
|
57
|
+
|
|
71
58
|
active_pixels_vs_threshold(x_train)
|
|
72
|
-
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)
|
|
73
60
|
```
|
|
74
61
|
|
|
75
62
|
Build an example sparse CNN within HGQ2 quantization scopes. A custom input quantizer config with
|
|
76
|
-
higher initial fractional bits (`f0=8`) prevents the default (`f0=2`) from zeroing out sparse signals
|
|
77
|
-
in early training epochs. `InputReduce` keeps the first `n` active pixels (first channel above
|
|
78
|
-
`threshold`)
|
|
79
|
-
|
|
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.
|
|
80
67
|
|
|
81
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
|
+
|
|
82
75
|
iq_conf = QuantizerConfig(place='datalane', q_type='kif', i0=4, f0=8, overflow_mode='WRAP')
|
|
83
76
|
|
|
84
77
|
with (
|
|
@@ -88,72 +81,84 @@ with (
|
|
|
88
81
|
):
|
|
89
82
|
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
90
83
|
|
|
91
|
-
# Sparse input reduction
|
|
92
84
|
x, keep_mask = InputReduce(
|
|
93
|
-
n=30,
|
|
94
|
-
threshold=0.1,
|
|
95
|
-
beta_n=
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
98
91
|
name='input_reduce',
|
|
99
92
|
)(x_in)
|
|
100
|
-
|
|
101
|
-
# Sparse convolution
|
|
102
93
|
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
103
94
|
activation='relu', iq_conf=iq_conf)([x, keep_mask])
|
|
104
|
-
|
|
105
|
-
# Sparse pooling
|
|
106
95
|
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
107
96
|
|
|
108
|
-
x = Flatten(name='flatten')(x)
|
|
97
|
+
x = keras.layers.Flatten(name='flatten')(x)
|
|
109
98
|
x = QDense(10, name='dense1', activation='relu', iq_conf=iq_conf)(x)
|
|
110
|
-
x = Activation('softmax', name='softmax')(x)
|
|
99
|
+
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
111
100
|
|
|
112
101
|
model = keras.Model(x_in, x)
|
|
113
102
|
```
|
|
114
103
|
|
|
115
|
-
Train the model
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
over-compressing near the end of training. `plot_history` shows the loss breakdown, the learned
|
|
119
|
-
budget/threshold and the EBOPS in one figure, and the values to deploy are `layer.n_max_pixels` and
|
|
120
|
-
`layer.threshold`.
|
|
104
|
+
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
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.
|
|
121
107
|
|
|
122
108
|
```python
|
|
123
|
-
|
|
109
|
+
from sparsepixels.utils import SparseTrainingMonitor
|
|
124
110
|
|
|
125
|
-
steps_per_epoch = len(x_train) // 128
|
|
126
111
|
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max', patience=20, restore_best_weights=True)
|
|
127
112
|
model.compile(
|
|
128
|
-
optimizer=keras.optimizers.Adam(
|
|
113
|
+
optimizer=keras.optimizers.Adam(1e-3),
|
|
129
114
|
loss='categorical_crossentropy', metrics=['accuracy'],
|
|
130
115
|
)
|
|
131
116
|
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
132
117
|
epochs=100, batch_size=128, callbacks=[early_stop, SparseTrainingMonitor()])
|
|
118
|
+
```
|
|
119
|
+
|
|
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).
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from sparsepixels.utils import plot_history, print_quantization, plot_quantization
|
|
133
124
|
|
|
134
|
-
plot_history(history, early_stopping=early_stop)
|
|
135
|
-
print_quantization(model)
|
|
125
|
+
plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
|
|
126
|
+
print_quantization(model) # per-layer bit-width distribution and EBOPS
|
|
136
127
|
plot_quantization(model)
|
|
137
128
|
|
|
138
129
|
ir = model.get_layer('input_reduce')
|
|
139
|
-
print(f"
|
|
130
|
+
print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
140
131
|
```
|
|
141
132
|
|
|
142
133
|
## Converting a trained model to HLS with hls4ml
|
|
143
134
|
|
|
144
|
-
> **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:
|
|
145
136
|
>
|
|
146
137
|
> ```bash
|
|
147
138
|
> pip install "git+https://github.com/hftsoi/hls4ml.git@sparsepixels"
|
|
148
139
|
> ```
|
|
149
140
|
|
|
150
|
-
Once installed,
|
|
141
|
+
Once installed, pull a config from the trained model, optionally set the per-layer parallelization
|
|
142
|
+
knobs, and convert:
|
|
151
143
|
|
|
152
144
|
```python
|
|
153
145
|
import hls4ml
|
|
154
146
|
|
|
155
147
|
hls_config = hls4ml.utils.config_from_keras_model(model, granularity='name')
|
|
156
|
-
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' #
|
|
148
|
+
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' # "#pragma HLS DATAFLOW"
|
|
149
|
+
|
|
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
|
|
157
162
|
|
|
158
163
|
hls_model = hls4ml.converters.convert_from_keras_model(
|
|
159
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,40 +26,33 @@ With Python >= 3.10:
|
|
|
26
26
|
pip install sparsepixels
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Import the sparse layers, the quantization library (HGQ2), and the training utilities:
|
|
29
|
+
This includes all necessary backends (tensorflow, keras, HGQ2).
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
import keras
|
|
35
|
-
from keras.layers import Flatten, Activation
|
|
36
|
-
from hgq.layers import QConv2D, QDense
|
|
37
|
-
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
38
|
-
from hgq.quantizer.config import QuantizerConfig
|
|
39
|
-
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
40
|
-
from sparsepixels.utils import (
|
|
41
|
-
active_pixels_vs_threshold, plot_reduced_examples,
|
|
42
|
-
set_sparse_ebops_factor, cosine_lr,
|
|
43
|
-
SparseTrainingMonitor, plot_history,
|
|
44
|
-
print_quantization, plot_quantization,
|
|
45
|
-
)
|
|
46
|
-
```
|
|
31
|
+
## Getting Started
|
|
47
32
|
|
|
48
33
|
First, study the data to pick a threshold and an initial pixel budget `n`: how many pixels stay
|
|
49
|
-
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.
|
|
50
35
|
|
|
51
36
|
```python
|
|
37
|
+
from sparsepixels.utils import active_pixels_vs_threshold, plot_reduced_examples
|
|
38
|
+
|
|
52
39
|
active_pixels_vs_threshold(x_train)
|
|
53
|
-
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)
|
|
54
41
|
```
|
|
55
42
|
|
|
56
43
|
Build an example sparse CNN within HGQ2 quantization scopes. A custom input quantizer config with
|
|
57
|
-
higher initial fractional bits (`f0=8`) prevents the default (`f0=2`) from zeroing out sparse signals
|
|
58
|
-
in early training epochs. `InputReduce` keeps the first `n` active pixels (first channel above
|
|
59
|
-
`threshold`)
|
|
60
|
-
|
|
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.
|
|
61
48
|
|
|
62
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
|
+
|
|
63
56
|
iq_conf = QuantizerConfig(place='datalane', q_type='kif', i0=4, f0=8, overflow_mode='WRAP')
|
|
64
57
|
|
|
65
58
|
with (
|
|
@@ -69,72 +62,84 @@ with (
|
|
|
69
62
|
):
|
|
70
63
|
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
71
64
|
|
|
72
|
-
# Sparse input reduction
|
|
73
65
|
x, keep_mask = InputReduce(
|
|
74
|
-
n=30,
|
|
75
|
-
threshold=0.1,
|
|
76
|
-
beta_n=
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
79
72
|
name='input_reduce',
|
|
80
73
|
)(x_in)
|
|
81
|
-
|
|
82
|
-
# Sparse convolution
|
|
83
74
|
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
84
75
|
activation='relu', iq_conf=iq_conf)([x, keep_mask])
|
|
85
|
-
|
|
86
|
-
# Sparse pooling
|
|
87
76
|
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
88
77
|
|
|
89
|
-
x = Flatten(name='flatten')(x)
|
|
78
|
+
x = keras.layers.Flatten(name='flatten')(x)
|
|
90
79
|
x = QDense(10, name='dense1', activation='relu', iq_conf=iq_conf)(x)
|
|
91
|
-
x = Activation('softmax', name='softmax')(x)
|
|
80
|
+
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
92
81
|
|
|
93
82
|
model = keras.Model(x_in, x)
|
|
94
83
|
```
|
|
95
84
|
|
|
96
|
-
Train the model
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
over-compressing near the end of training. `plot_history` shows the loss breakdown, the learned
|
|
100
|
-
budget/threshold and the EBOPS in one figure, and the values to deploy are `layer.n_max_pixels` and
|
|
101
|
-
`layer.threshold`.
|
|
85
|
+
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
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.
|
|
102
88
|
|
|
103
89
|
```python
|
|
104
|
-
|
|
90
|
+
from sparsepixels.utils import SparseTrainingMonitor
|
|
105
91
|
|
|
106
|
-
steps_per_epoch = len(x_train) // 128
|
|
107
92
|
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max', patience=20, restore_best_weights=True)
|
|
108
93
|
model.compile(
|
|
109
|
-
optimizer=keras.optimizers.Adam(
|
|
94
|
+
optimizer=keras.optimizers.Adam(1e-3),
|
|
110
95
|
loss='categorical_crossentropy', metrics=['accuracy'],
|
|
111
96
|
)
|
|
112
97
|
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
113
98
|
epochs=100, batch_size=128, callbacks=[early_stop, SparseTrainingMonitor()])
|
|
99
|
+
```
|
|
100
|
+
|
|
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).
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from sparsepixels.utils import plot_history, print_quantization, plot_quantization
|
|
114
105
|
|
|
115
|
-
plot_history(history, early_stopping=early_stop)
|
|
116
|
-
print_quantization(model)
|
|
106
|
+
plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
|
|
107
|
+
print_quantization(model) # per-layer bit-width distribution and EBOPS
|
|
117
108
|
plot_quantization(model)
|
|
118
109
|
|
|
119
110
|
ir = model.get_layer('input_reduce')
|
|
120
|
-
print(f"
|
|
111
|
+
print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
121
112
|
```
|
|
122
113
|
|
|
123
114
|
## Converting a trained model to HLS with hls4ml
|
|
124
115
|
|
|
125
|
-
> **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:
|
|
126
117
|
>
|
|
127
118
|
> ```bash
|
|
128
119
|
> pip install "git+https://github.com/hftsoi/hls4ml.git@sparsepixels"
|
|
129
120
|
> ```
|
|
130
121
|
|
|
131
|
-
Once installed,
|
|
122
|
+
Once installed, pull a config from the trained model, optionally set the per-layer parallelization
|
|
123
|
+
knobs, and convert:
|
|
132
124
|
|
|
133
125
|
```python
|
|
134
126
|
import hls4ml
|
|
135
127
|
|
|
136
128
|
hls_config = hls4ml.utils.config_from_keras_model(model, granularity='name')
|
|
137
|
-
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' #
|
|
129
|
+
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' # "#pragma HLS DATAFLOW"
|
|
130
|
+
|
|
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
|
|
138
143
|
|
|
139
144
|
hls_model = hls4ml.converters.convert_from_keras_model(
|
|
140
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
|
|
@@ -49,9 +48,10 @@ def set_sparse_ebops_factor(model):
|
|
|
49
48
|
HGQ counts a conv's EBOPS as if it ran over the whole H*W feature map, which overestimates a
|
|
50
49
|
sparse conv that only touches about n_max_pixels of them. This scales each sparse conv's EBOPS by
|
|
51
50
|
n_max_pixels / (H*W) through HGQ's own ebops_factor, so both the reported EBOPS and the EBOPS
|
|
52
|
-
regularization loss reflect the sparse compute.
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
regularization loss reflect the sparse compute. SparseTrainingMonitor applies this automatically
|
|
52
|
+
at the start of training, so you usually do not need to call it directly -- call it manually only
|
|
53
|
+
to correct EBOPS you inspect before training, or to refresh it if a learnable budget moves a lot
|
|
54
|
+
(it uses the current n_max_pixels, a good approximation since the budget moves slowly).
|
|
55
55
|
|
|
56
56
|
Returns a dict of {layer_name: factor} for the sparse convs that were adjusted.
|
|
57
57
|
"""
|
|
@@ -115,6 +115,20 @@ def _budget_reg_loss(model):
|
|
|
115
115
|
return total
|
|
116
116
|
|
|
117
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
|
+
|
|
118
132
|
def _fmt_bits(bits_tensor):
|
|
119
133
|
b = np.array(bits_tensor).flatten()
|
|
120
134
|
if b.size == 1:
|
|
@@ -157,12 +171,21 @@ class SparseTrainingMonitor(Callback):
|
|
|
157
171
|
|
|
158
172
|
- n_max_pixels and threshold: the learned or fixed pixel budget and threshold of the InputReduce
|
|
159
173
|
layer (suffixed with the layer name when there is more than one).
|
|
160
|
-
- ebops: total EBOPS over the quantized layers, a proxy for the quantized hardware cost
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
- ebops: total EBOPS over the quantized layers, a proxy for the quantized hardware cost. This
|
|
175
|
+
monitor applies set_sparse_ebops_factor at the start of training, so the EBOPS (and its
|
|
176
|
+
regularizer) reflect the sparse (active-pixel) compute without any manual call.
|
|
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.
|
|
164
181
|
"""
|
|
165
182
|
|
|
183
|
+
def on_train_begin(self, logs=None):
|
|
184
|
+
# Fold in set_sparse_ebops_factor so the user need not call it manually. Uses the current
|
|
185
|
+
# (initial) budget, matching a manual call right before fit; call it again yourself if a
|
|
186
|
+
# learnable budget moves a lot during training.
|
|
187
|
+
set_sparse_ebops_factor(self.model)
|
|
188
|
+
|
|
166
189
|
def on_epoch_end(self, epoch, logs=None):
|
|
167
190
|
if logs is None:
|
|
168
191
|
return
|
|
@@ -177,14 +200,18 @@ class SparseTrainingMonitor(Callback):
|
|
|
177
200
|
reg_ebops = _ebops_reg_loss(self.model) if found else 0.0
|
|
178
201
|
has_budget = any(getattr(ir, "learn_n", False) for ir in irs)
|
|
179
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
|
|
180
205
|
if found:
|
|
181
206
|
logs["ebops"] = ebops
|
|
182
207
|
logs["loss_ebops"] = reg_ebops
|
|
183
208
|
if has_budget:
|
|
184
209
|
logs["loss_n"] = reg_n
|
|
210
|
+
if has_maskedE:
|
|
211
|
+
logs["loss_maskedE"] = reg_maskedE
|
|
185
212
|
# Task loss is the total minus the (train-only) penalties, so the parts add up to the loss.
|
|
186
213
|
if "loss" in logs:
|
|
187
|
-
logs["loss_task"] = logs["loss"] - reg_ebops - reg_n
|
|
214
|
+
logs["loss_task"] = logs["loss"] - reg_ebops - reg_n - reg_maskedE
|
|
188
215
|
|
|
189
216
|
|
|
190
217
|
def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
@@ -212,7 +239,7 @@ def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
|
212
239
|
|
|
213
240
|
def _is_single(k):
|
|
214
241
|
return (
|
|
215
|
-
k in ("ebops", "loss_task", "loss_ebops", "loss_n")
|
|
242
|
+
k in ("ebops", "loss_task", "loss_ebops", "loss_n", "loss_maskedE")
|
|
216
243
|
or k.startswith("n_max_pixels")
|
|
217
244
|
or k.startswith("threshold")
|
|
218
245
|
)
|
|
@@ -222,7 +249,7 @@ def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
|
222
249
|
metric_keys = [k for k in h if not k.startswith("val_") and k not in skip]
|
|
223
250
|
m_order = ["loss"]
|
|
224
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]
|
|
225
|
-
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"]
|
|
226
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]
|
|
227
254
|
|
|
228
255
|
panels = metric_keys + single_keys
|
|
@@ -246,7 +273,7 @@ def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
|
246
273
|
show_legend = True
|
|
247
274
|
else:
|
|
248
275
|
vals = list(h[key])
|
|
249
|
-
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"
|
|
250
277
|
ax.plot(ep, vals, marker=".", color=color)
|
|
251
278
|
# annotate first and last epoch (above the point) so the endpoints are easy to read
|
|
252
279
|
for xi, yi in ((1, vals[0]), (len(vals), vals[-1])):
|
|
@@ -284,26 +311,6 @@ def plot_history(history, early_stopping=None, figsize=None, ncols=3):
|
|
|
284
311
|
plt.show()
|
|
285
312
|
|
|
286
313
|
|
|
287
|
-
def cosine_lr(initial_learning_rate, epochs, steps_per_epoch, alpha=0.0):
|
|
288
|
-
"""Cosine-decay learning-rate schedule spanning the whole training run.
|
|
289
|
-
|
|
290
|
-
A decaying rate lets the learnable budget and threshold move quickly early and then settle late,
|
|
291
|
-
instead of drifting the whole time (which is what pushes the budget to over-compress near the
|
|
292
|
-
end). Pass the result as the optimizer's learning_rate, and pair it with an EarlyStopping that
|
|
293
|
-
monitors val_accuracy with restore_best_weights so the best-validation values are the ones you
|
|
294
|
-
deploy.
|
|
295
|
-
|
|
296
|
-
Args:
|
|
297
|
-
initial_learning_rate: starting learning rate, e.g. 1e-3.
|
|
298
|
-
epochs: number of training epochs.
|
|
299
|
-
steps_per_epoch: batches per epoch, i.e. ceil(len(x_train) / batch_size).
|
|
300
|
-
alpha: final learning rate as a fraction of the initial (0 decays to about 0).
|
|
301
|
-
"""
|
|
302
|
-
return keras.optimizers.schedules.CosineDecay(
|
|
303
|
-
initial_learning_rate, decay_steps=int(epochs * steps_per_epoch), alpha=alpha
|
|
304
|
-
)
|
|
305
|
-
|
|
306
|
-
|
|
307
314
|
def active_pixels_vs_threshold(x, thresholds=None, channel=0, percentiles=(25, 50, 75), plot=True, ax=None):
|
|
308
315
|
"""Study how the number of active pixels per image changes with the threshold.
|
|
309
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,40 +45,33 @@ With Python >= 3.10:
|
|
|
45
45
|
pip install sparsepixels
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
Import the sparse layers, the quantization library (HGQ2), and the training utilities:
|
|
48
|
+
This includes all necessary backends (tensorflow, keras, HGQ2).
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
import keras
|
|
54
|
-
from keras.layers import Flatten, Activation
|
|
55
|
-
from hgq.layers import QConv2D, QDense
|
|
56
|
-
from hgq.config import QuantizerConfigScope, LayerConfigScope
|
|
57
|
-
from hgq.quantizer.config import QuantizerConfig
|
|
58
|
-
from sparsepixels.layers import InputReduce, QConv2DSparse, AveragePooling2DSparse, MaxPooling2DSparse
|
|
59
|
-
from sparsepixels.utils import (
|
|
60
|
-
active_pixels_vs_threshold, plot_reduced_examples,
|
|
61
|
-
set_sparse_ebops_factor, cosine_lr,
|
|
62
|
-
SparseTrainingMonitor, plot_history,
|
|
63
|
-
print_quantization, plot_quantization,
|
|
64
|
-
)
|
|
65
|
-
```
|
|
50
|
+
## Getting Started
|
|
66
51
|
|
|
67
52
|
First, study the data to pick a threshold and an initial pixel budget `n`: how many pixels stay
|
|
68
|
-
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.
|
|
69
54
|
|
|
70
55
|
```python
|
|
56
|
+
from sparsepixels.utils import active_pixels_vs_threshold, plot_reduced_examples
|
|
57
|
+
|
|
71
58
|
active_pixels_vs_threshold(x_train)
|
|
72
|
-
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)
|
|
73
60
|
```
|
|
74
61
|
|
|
75
62
|
Build an example sparse CNN within HGQ2 quantization scopes. A custom input quantizer config with
|
|
76
|
-
higher initial fractional bits (`f0=8`) prevents the default (`f0=2`) from zeroing out sparse signals
|
|
77
|
-
in early training epochs. `InputReduce` keeps the first `n` active pixels (first channel above
|
|
78
|
-
`threshold`)
|
|
79
|
-
|
|
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.
|
|
80
67
|
|
|
81
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
|
+
|
|
82
75
|
iq_conf = QuantizerConfig(place='datalane', q_type='kif', i0=4, f0=8, overflow_mode='WRAP')
|
|
83
76
|
|
|
84
77
|
with (
|
|
@@ -88,72 +81,84 @@ with (
|
|
|
88
81
|
):
|
|
89
82
|
x_in = keras.Input(shape=(28, 28, 1), name='x_in')
|
|
90
83
|
|
|
91
|
-
# Sparse input reduction
|
|
92
84
|
x, keep_mask = InputReduce(
|
|
93
|
-
n=30,
|
|
94
|
-
threshold=0.1,
|
|
95
|
-
beta_n=
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
98
91
|
name='input_reduce',
|
|
99
92
|
)(x_in)
|
|
100
|
-
|
|
101
|
-
# Sparse convolution
|
|
102
93
|
x = QConv2DSparse(filters=3, kernel_size=3, name='conv1', padding='same', strides=1,
|
|
103
94
|
activation='relu', iq_conf=iq_conf)([x, keep_mask])
|
|
104
|
-
|
|
105
|
-
# Sparse pooling
|
|
106
95
|
x, keep_mask = AveragePooling2DSparse(2, name='pool1')([x, keep_mask])
|
|
107
96
|
|
|
108
|
-
x = Flatten(name='flatten')(x)
|
|
97
|
+
x = keras.layers.Flatten(name='flatten')(x)
|
|
109
98
|
x = QDense(10, name='dense1', activation='relu', iq_conf=iq_conf)(x)
|
|
110
|
-
x = Activation('softmax', name='softmax')(x)
|
|
99
|
+
x = keras.layers.Activation('softmax', name='softmax')(x)
|
|
111
100
|
|
|
112
101
|
model = keras.Model(x_in, x)
|
|
113
102
|
```
|
|
114
103
|
|
|
115
|
-
Train the model
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
over-compressing near the end of training. `plot_history` shows the loss breakdown, the learned
|
|
119
|
-
budget/threshold and the EBOPS in one figure, and the values to deploy are `layer.n_max_pixels` and
|
|
120
|
-
`layer.threshold`.
|
|
104
|
+
Train the model with `SparseTrainingMonitor`. It records the loss breakdown, the learned
|
|
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.
|
|
121
107
|
|
|
122
108
|
```python
|
|
123
|
-
|
|
109
|
+
from sparsepixels.utils import SparseTrainingMonitor
|
|
124
110
|
|
|
125
|
-
steps_per_epoch = len(x_train) // 128
|
|
126
111
|
early_stop = keras.callbacks.EarlyStopping(monitor='val_accuracy', mode='max', patience=20, restore_best_weights=True)
|
|
127
112
|
model.compile(
|
|
128
|
-
optimizer=keras.optimizers.Adam(
|
|
113
|
+
optimizer=keras.optimizers.Adam(1e-3),
|
|
129
114
|
loss='categorical_crossentropy', metrics=['accuracy'],
|
|
130
115
|
)
|
|
131
116
|
history = model.fit(x_train, y_train, validation_data=(x_val, y_val),
|
|
132
117
|
epochs=100, batch_size=128, callbacks=[early_stop, SparseTrainingMonitor()])
|
|
118
|
+
```
|
|
119
|
+
|
|
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).
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from sparsepixels.utils import plot_history, print_quantization, plot_quantization
|
|
133
124
|
|
|
134
|
-
plot_history(history, early_stopping=early_stop)
|
|
135
|
-
print_quantization(model)
|
|
125
|
+
plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
|
|
126
|
+
print_quantization(model) # per-layer bit-width distribution and EBOPS
|
|
136
127
|
plot_quantization(model)
|
|
137
128
|
|
|
138
129
|
ir = model.get_layer('input_reduce')
|
|
139
|
-
print(f"
|
|
130
|
+
print(f"n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
|
|
140
131
|
```
|
|
141
132
|
|
|
142
133
|
## Converting a trained model to HLS with hls4ml
|
|
143
134
|
|
|
144
|
-
> **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:
|
|
145
136
|
>
|
|
146
137
|
> ```bash
|
|
147
138
|
> pip install "git+https://github.com/hftsoi/hls4ml.git@sparsepixels"
|
|
148
139
|
> ```
|
|
149
140
|
|
|
150
|
-
Once installed,
|
|
141
|
+
Once installed, pull a config from the trained model, optionally set the per-layer parallelization
|
|
142
|
+
knobs, and convert:
|
|
151
143
|
|
|
152
144
|
```python
|
|
153
145
|
import hls4ml
|
|
154
146
|
|
|
155
147
|
hls_config = hls4ml.utils.config_from_keras_model(model, granularity='name')
|
|
156
|
-
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' #
|
|
148
|
+
hls_config.setdefault('Model', {})['PipelineStyle'] = 'dataflow' # "#pragma HLS DATAFLOW"
|
|
149
|
+
|
|
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
|
|
157
162
|
|
|
158
163
|
hls_model = hls4ml.converters.convert_from_keras_model(
|
|
159
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
|