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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sparsepixels
3
- Version: 0.3.0
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,40 +45,33 @@ With Python >= 3.10:
45
45
  pip install sparsepixels
46
46
  ```
47
47
 
48
- ## Getting Started
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
- ```python
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)` 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.
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=4)
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`); by default `n` and `threshold` are trainable hyperparameters, and a penalty of weight `beta_n`
79
- 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.
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, # initial pixel budget
94
- threshold=0.1, # initial activity threshold
95
- beta_n=1e-5, # weight of the pixel budget penalty
96
- learn_n=True, # trainable pixel budget
97
- 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
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, then read out the learned sparsity to deploy. `set_sparse_ebops_factor` makes the
116
- EBOPS (a proxy for the quantized hardware cost) reflect the sparse compute rather than a dense one; a
117
- cosine-decayed learning rate together with `restore_best_weights` keeps the learned budget from
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
- set_sparse_ebops_factor(model)
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(cosine_lr(1e-3, epochs=100, steps_per_epoch=steps_per_epoch)),
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) # loss breakdown, budget, threshold, EBOPS
135
- print_quantization(model) # per-layer bit-width distribution and EBOPS
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"deploy with n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
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 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:
145
136
  >
146
137
  > ```bash
147
138
  > pip install "git+https://github.com/hftsoi/hls4ml.git@sparsepixels"
148
139
  > ```
149
140
 
150
- Once installed, converting a trained sparsepixels model to HLS is as usual:
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' # use "#pragma HLS DATAFLOW" (instead of the default "#pragma HLS PIPELINE" for io_parallel)
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
  [![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,40 +26,33 @@ With Python >= 3.10:
26
26
  pip install sparsepixels
27
27
  ```
28
28
 
29
- ## Getting Started
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
- ```python
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)` 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.
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=4)
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`); by default `n` and `threshold` are trainable hyperparameters, and a penalty of weight `beta_n`
60
- 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.
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, # initial pixel budget
75
- threshold=0.1, # initial activity threshold
76
- beta_n=1e-5, # weight of the pixel budget penalty
77
- learn_n=True, # trainable pixel budget
78
- 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
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, then read out the learned sparsity to deploy. `set_sparse_ebops_factor` makes the
97
- EBOPS (a proxy for the quantized hardware cost) reflect the sparse compute rather than a dense one; a
98
- cosine-decayed learning rate together with `restore_best_weights` keeps the learned budget from
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
- set_sparse_ebops_factor(model)
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(cosine_lr(1e-3, epochs=100, steps_per_epoch=steps_per_epoch)),
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) # loss breakdown, budget, threshold, EBOPS
116
- print_quantization(model) # per-layer bit-width distribution and EBOPS
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"deploy with n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
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 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:
126
117
  >
127
118
  > ```bash
128
119
  > pip install "git+https://github.com/hftsoi/hls4ml.git@sparsepixels"
129
120
  > ```
130
121
 
131
- Once installed, converting a trained sparsepixels model to HLS is as usual:
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' # use "#pragma HLS DATAFLOW" (instead of the default "#pragma HLS PIPELINE" for io_parallel)
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:
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = sparsepixels
3
- version = 0.3.0
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
@@ -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. Call once after building the model, before fit.
53
- For a learnable budget it uses the current n_max_pixels, which is a good approximation since the
54
- budget moves slowly; call it again if the budget changes a lot.
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 (sparse
161
- corrected once set_sparse_ebops_factor has been applied).
162
- - loss_task, loss_ebops, loss_n: the training loss split into the task loss, the EBOPS penalty and
163
- the pixel-budget penalty, each already scaled by its weight so the three add up to the loss.
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.0
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,40 +45,33 @@ With Python >= 3.10:
45
45
  pip install sparsepixels
46
46
  ```
47
47
 
48
- ## Getting Started
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
- ```python
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)` 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.
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=4)
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`); by default `n` and `threshold` are trainable hyperparameters, and a penalty of weight `beta_n`
79
- 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.
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, # initial pixel budget
94
- threshold=0.1, # initial activity threshold
95
- beta_n=1e-5, # weight of the pixel budget penalty
96
- learn_n=True, # trainable pixel budget
97
- 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
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, then read out the learned sparsity to deploy. `set_sparse_ebops_factor` makes the
116
- EBOPS (a proxy for the quantized hardware cost) reflect the sparse compute rather than a dense one; a
117
- cosine-decayed learning rate together with `restore_best_weights` keeps the learned budget from
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
- set_sparse_ebops_factor(model)
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(cosine_lr(1e-3, epochs=100, steps_per_epoch=steps_per_epoch)),
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) # loss breakdown, budget, threshold, EBOPS
135
- print_quantization(model) # per-layer bit-width distribution and EBOPS
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"deploy with n_max_pixels={ir.n_max_pixels}, threshold={ir.threshold:.3f}")
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 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:
145
136
  >
146
137
  > ```bash
147
138
  > pip install "git+https://github.com/hftsoi/hls4ml.git@sparsepixels"
148
139
  > ```
149
140
 
150
- Once installed, converting a trained sparsepixels model to HLS is as usual:
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' # use "#pragma HLS DATAFLOW" (instead of the default "#pragma HLS PIPELINE" for io_parallel)
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,
@@ -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