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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sparsepixels
3
- Version: 0.3.1
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
  [![arXiv](https://img.shields.io/badge/arXiv-2512.06208-b31b1b.svg?style=flat-square)](https://arxiv.org/abs/2512.06208)
36
36
  [![PyPI - Version](https://img.shields.io/pypi/v/sparsepixels?color=orange&style=flat-square)](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 (for example in a trigger) feasible on an FPGA. This library builds quantization-aware (via [HGQ2](https://github.com/calad0i/HGQ2)) 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 to trade latency against resource usage.
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
- ## Getting Started
48
+ This includes all necessary backends (tensorflow, keras, HGQ2).
49
49
 
50
- ```python
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)` keeps on a few images.
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=4)
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`); by default `n` and `threshold` are trainable hyperparameters, and a penalty of weight `beta_n`
75
- nudges the budget smaller, trading a little accuracy for lower FPGA latency and resources.
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, # initial pixel budget
90
- threshold=0.1, # initial activity threshold
91
- beta_n=1e-5, # weight of the pixel budget penalty
92
- learn_n=True, # trainable pixel budget
93
- learn_threshold=True, # trainable threshold
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 EBOPS each epoch, and it corrects the EBOPS (a proxy for the quantized
113
- hardware cost) to the sparse compute automatically, so no extra setup is needed. The
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(cosine_lr(1e-3, epochs=100, steps_per_epoch=len(x_train) // 128)),
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 read out the learned sparsity to deploy. `plot_history`
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
- plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
134
- print_quantization(model) # per-layer bit-width distribution and EBOPS
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 try the converter:
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
- # Optional per-layer parallelization knobs. Omit them for the fully-parallel, lowest-latency default.
159
- # input_reduce Variant : 'tree' (default, lowest latency) or 'stream' (fewer resources)
160
- # conv* PixelParallelFactor : active pixels in parallel (<= n_max_pixels)
161
- # FiltParallelFactor : filters in parallel (<= that conv's filters)
162
- # pool* PixelParallelFactor : active pixels in parallel
163
- # ChanParallelFactor : channels in parallel
164
- # flatten ParallelFactor : scatter positions in parallel (<= out_height * out_width)
165
- n_sparse = model.get_layer('input_reduce').n_max_pixels
166
- knobs = {
167
- 'input_reduce': {'Variant': 'stream'},
168
- 'conv1': {'PixelParallelFactor': n_sparse, 'FiltParallelFactor': 3},
169
- 'pool1': {'PixelParallelFactor': n_sparse, 'ChanParallelFactor': 3},
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
  [![arXiv](https://img.shields.io/badge/arXiv-2512.06208-b31b1b.svg?style=flat-square)](https://arxiv.org/abs/2512.06208)
17
17
  [![PyPI - Version](https://img.shields.io/pypi/v/sparsepixels?color=orange&style=flat-square)](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 (for example in a trigger) feasible on an FPGA. This library builds quantization-aware (via [HGQ2](https://github.com/calad0i/HGQ2)) 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 to trade latency against resource usage.
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
- ## Getting Started
29
+ This includes all necessary backends (tensorflow, keras, HGQ2).
30
30
 
31
- ```python
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)` keeps on a few images.
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=4)
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`); by default `n` and `threshold` are trainable hyperparameters, and a penalty of weight `beta_n`
56
- nudges the budget smaller, trading a little accuracy for lower FPGA latency and resources.
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, # initial pixel budget
71
- threshold=0.1, # initial activity threshold
72
- beta_n=1e-5, # weight of the pixel budget penalty
73
- learn_n=True, # trainable pixel budget
74
- learn_threshold=True, # trainable threshold
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 EBOPS each epoch, and it corrects the EBOPS (a proxy for the quantized
94
- hardware cost) to the sparse compute automatically, so no extra setup is needed. The
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(cosine_lr(1e-3, epochs=100, steps_per_epoch=len(x_train) // 128)),
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 read out the learned sparsity to deploy. `plot_history`
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
- plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
115
- print_quantization(model) # per-layer bit-width distribution and EBOPS
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 try the converter:
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
- # Optional per-layer parallelization knobs. Omit them for the fully-parallel, lowest-latency default.
140
- # input_reduce Variant : 'tree' (default, lowest latency) or 'stream' (fewer resources)
141
- # conv* PixelParallelFactor : active pixels in parallel (<= n_max_pixels)
142
- # FiltParallelFactor : filters in parallel (<= that conv's filters)
143
- # pool* PixelParallelFactor : active pixels in parallel
144
- # ChanParallelFactor : channels in parallel
145
- # flatten ParallelFactor : scatter positions in parallel (<= out_height * out_width)
146
- n_sparse = model.get_layer('input_reduce').n_max_pixels
147
- knobs = {
148
- 'input_reduce': {'Variant': 'stream'},
149
- 'conv1': {'PixelParallelFactor': n_sparse, 'FiltParallelFactor': 3},
150
- 'pool1': {'PixelParallelFactor': n_sparse, 'ChanParallelFactor': 3},
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:
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = sparsepixels
3
- version = 0.3.1
3
+ version = 0.3.2
4
4
  description = Efficient convolution for sparse data on FPGAs
5
5
  author = Ho Fung Tsoi
6
6
  author_email = ho.fung.tsoi@cern.ch
@@ -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 stays within [1, 4*n]. After training, read the values
36
- to deploy from the n_max_pixels and threshold properties.
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=1e-5,
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, 4x] so it can shrink or modestly grow.
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, 4.0),
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 EBOPS penalty and
165
- the pixel-budget penalty, each already scaled by its weight so the three add up to the loss.
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.1
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
  [![arXiv](https://img.shields.io/badge/arXiv-2512.06208-b31b1b.svg?style=flat-square)](https://arxiv.org/abs/2512.06208)
36
36
  [![PyPI - Version](https://img.shields.io/pypi/v/sparsepixels?color=orange&style=flat-square)](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 (for example in a trigger) feasible on an FPGA. This library builds quantization-aware (via [HGQ2](https://github.com/calad0i/HGQ2)) 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 to trade latency against resource usage.
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
- ## Getting Started
48
+ This includes all necessary backends (tensorflow, keras, HGQ2).
49
49
 
50
- ```python
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)` keeps on a few images.
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=4)
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`); by default `n` and `threshold` are trainable hyperparameters, and a penalty of weight `beta_n`
75
- nudges the budget smaller, trading a little accuracy for lower FPGA latency and resources.
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, # initial pixel budget
90
- threshold=0.1, # initial activity threshold
91
- beta_n=1e-5, # weight of the pixel budget penalty
92
- learn_n=True, # trainable pixel budget
93
- learn_threshold=True, # trainable threshold
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 EBOPS each epoch, and it corrects the EBOPS (a proxy for the quantized
113
- hardware cost) to the sparse compute automatically, so no extra setup is needed. The
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(cosine_lr(1e-3, epochs=100, steps_per_epoch=len(x_train) // 128)),
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 read out the learned sparsity to deploy. `plot_history`
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
- plot_history(history, early_stopping=early_stop) # loss breakdown, budget, threshold, EBOPS
134
- print_quantization(model) # per-layer bit-width distribution and EBOPS
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 try the converter:
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
- # Optional per-layer parallelization knobs. Omit them for the fully-parallel, lowest-latency default.
159
- # input_reduce Variant : 'tree' (default, lowest latency) or 'stream' (fewer resources)
160
- # conv* PixelParallelFactor : active pixels in parallel (<= n_max_pixels)
161
- # FiltParallelFactor : filters in parallel (<= that conv's filters)
162
- # pool* PixelParallelFactor : active pixels in parallel
163
- # ChanParallelFactor : channels in parallel
164
- # flatten ParallelFactor : scatter positions in parallel (<= out_height * out_width)
165
- n_sparse = model.get_layer('input_reduce').n_max_pixels
166
- knobs = {
167
- 'input_reduce': {'Variant': 'stream'},
168
- 'conv1': {'PixelParallelFactor': n_sparse, 'FiltParallelFactor': 3},
169
- 'pool1': {'PixelParallelFactor': n_sparse, 'ChanParallelFactor': 3},
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,
@@ -1,6 +1,5 @@
1
1
  dist
2
2
  docs
3
- hls_templates
4
3
  notebook
5
4
  sparsepixels
6
5
  tests
@@ -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, cosine_lr, plot_history, set_sparse_ebops_factor
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, cosine LR, EBOPS factor, monitor and plot on a tiny synthetic set
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(cosine_lr(1e-3, epochs=2, steps_per_epoch=steps)),
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