sparsepixels 0.3.2__tar.gz → 0.3.3__tar.gz

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