faultforge-cli 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,395 @@
1
+ """Figure-building for the `compare`/`heatmap` commands.
2
+
3
+ See `results` for the vocabulary (configuration, bit error rate, split key) and
4
+ the `compare` pipeline this is the final step of. Figures are built via
5
+ `matplotlib`'s `Figure` object-oriented API directly (never
6
+ `matplotlib.pyplot`), so this module never touches `pyplot`'s global "current
7
+ figure" state; only `commands` imports `pyplot`, for `plt.show()`.
8
+
9
+ `matplotlib` is loosely typed - several of its calls hand back `Any` - so
10
+ extracted axes are pinned to `Axes` with `assert isinstance` at the boundary,
11
+ which keeps editor tooling useful in the rest of the function.
12
+ """
13
+
14
+ import enum
15
+ import logging
16
+ from collections.abc import Sequence
17
+ from typing import Any
18
+
19
+ import matplotlib
20
+ import numpy as np
21
+ from faultforge import Fingerprint
22
+ from faultforge.dtype import EncodingDtype
23
+ from faultforge.experiments.encoded_memory import (
24
+ DetailedResult,
25
+ ReliabilityMetric,
26
+ SavedResult,
27
+ )
28
+ from faultforge_cli.encoded_memory.results import (
29
+ Configuration,
30
+ bit_position_histogram,
31
+ configuration_points,
32
+ )
33
+ from matplotlib.axes import Axes
34
+ from matplotlib.colors import LogNorm
35
+ from matplotlib.figure import Figure
36
+ from matplotlib.lines import Line2D
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ _MARKERS = [
41
+ "o",
42
+ "v",
43
+ "^",
44
+ "<",
45
+ ">",
46
+ "s",
47
+ "p",
48
+ "P",
49
+ "*",
50
+ "h",
51
+ "+",
52
+ "X",
53
+ "D",
54
+ ]
55
+ """A fixed marker cycle so lines stay distinguishable without relying on color alone."""
56
+
57
+ _COLORS = [
58
+ "#1f77b4",
59
+ "#ff7f0e",
60
+ "#2ca02c",
61
+ "#d62728",
62
+ "#9467bd",
63
+ "#8c564b",
64
+ "#e377c2",
65
+ "#7f7f7f",
66
+ "#bcbd22",
67
+ "#17becf",
68
+ ]
69
+ """Matplotlib's default ("tab10") color cycle, assigned explicitly per label
70
+ (see `build_compare_figure`) rather than left to each `Axes`'s own color
71
+ cycler - the cycler resets per `Axes`, so two configurations plotted into
72
+ different, otherwise-empty grid cells would both land on its first color."""
73
+
74
+
75
+ class GroupBy(enum.StrEnum):
76
+ """A fingerprint-derived split key to fan a `compare` grid out by.
77
+
78
+ Distinct from a configuration (see `results`): this decides which grid
79
+ *cell* a line lands in, not which line results belong to.
80
+ """
81
+
82
+ Model = "model"
83
+ Dtype = "dtype"
84
+ Dataset = "dataset"
85
+ Metric = "metric"
86
+ Ungrouped = "none"
87
+ """No splitting - a single row/column. `None` can't be a member name (it's
88
+ a reserved keyword), but the CLI-facing value stays "none"."""
89
+
90
+
91
+ def group_key(group_by: GroupBy, fingerprint: Fingerprint) -> str | None:
92
+ """The grid cell value `fingerprint` belongs to under `group_by`.
93
+
94
+ `None` for `GroupBy.Ungrouped`.
95
+
96
+ Raises:
97
+ ValueError: For `GroupBy.Dataset` on a bundle with no `dataset`
98
+ scalar (e.g. an `ImageNet` bundle).
99
+ """
100
+ match group_by:
101
+ case GroupBy.Ungrouped:
102
+ return None
103
+ case GroupBy.Dtype:
104
+ return str(fingerprint.scalars["dtype"])
105
+ case GroupBy.Metric:
106
+ return str(fingerprint.scalars["reliability_metric"])
107
+ case GroupBy.Model:
108
+ return str(fingerprint.children["bundle"][0].scalars["model"])
109
+ case GroupBy.Dataset:
110
+ bundle = fingerprint.children["bundle"][0]
111
+ dataset = bundle.scalars.get("dataset")
112
+ if dataset is None:
113
+ raise ValueError(
114
+ f"{bundle.kind!r} bundle has no 'dataset' scalar to group by"
115
+ )
116
+ return str(dataset)
117
+
118
+
119
+ def _reliability_metric(configuration: Configuration) -> ReliabilityMetric:
120
+ _, first = configuration.results[0]
121
+ return first.reliability_metric()
122
+
123
+
124
+ def _cell(axes: Any, row: int, col: int) -> Axes:
125
+ """One grid cell out of `Figure.subplots`' loosely-typed 2D array."""
126
+ ax = axes[row][col]
127
+ assert isinstance(ax, Axes)
128
+ return ax
129
+
130
+
131
+ def build_compare_figure(
132
+ configurations: Sequence[Configuration],
133
+ *,
134
+ row_by: GroupBy = GroupBy.Ungrouped,
135
+ col_by: GroupBy = GroupBy.Ungrouped,
136
+ percentile: float | None = None,
137
+ log_x: bool = False,
138
+ ) -> Figure:
139
+ """Step 4 of the `compare` pipeline: one subplot per `(row_by, col_by)`
140
+ cell, one overlaid line per configuration within its cell.
141
+
142
+ Cell values are discovered in first-seen order across `configurations`,
143
+ sizing the grid automatically. A label recurring across several
144
+ configurations (e.g. the same encoder compared across several model/dtype
145
+ combinations) keeps the same marker/color wherever it appears, and only
146
+ contributes one entry to the shared figure-level legend.
147
+
148
+ Raises:
149
+ ValueError: If `configurations` is empty, if they don't all share a
150
+ `reliability_metric` (unlike model/dataset/dtype, it changes what
151
+ the y-axis numbers mean, not just what's being compared), or if
152
+ `row_by`/`col_by` raises (see `group_key`).
153
+ """
154
+ if not configurations:
155
+ raise ValueError("no configurations to plot")
156
+
157
+ metrics = {_reliability_metric(config) for config in configurations}
158
+ if len(metrics) > 1:
159
+ names = sorted(metric.value for metric in metrics)
160
+ raise ValueError(
161
+ f"all configurations must share a reliability metric to be "
162
+ f"compared, got {names}"
163
+ )
164
+ metric = next(iter(metrics))
165
+
166
+ row_values: list[str | None] = []
167
+ col_values: list[str | None] = []
168
+ cells: list[tuple[str | None, str | None]] = []
169
+ for config in configurations:
170
+ fingerprint = config.results[0][1].fingerprint
171
+ row_value = group_key(row_by, fingerprint)
172
+ col_value = group_key(col_by, fingerprint)
173
+ cells.append((row_value, col_value))
174
+ if row_value not in row_values:
175
+ row_values.append(row_value)
176
+ if col_value not in col_values:
177
+ col_values.append(col_value)
178
+
179
+ n_rows = len(row_values)
180
+ n_cols = len(col_values)
181
+
182
+ labels_in_order: list[str] = []
183
+ for config in configurations:
184
+ if config.label not in labels_in_order:
185
+ labels_in_order.append(config.label)
186
+ marker_by_label = {
187
+ label: _MARKERS[index % len(_MARKERS)]
188
+ for index, label in enumerate(labels_in_order)
189
+ }
190
+ color_by_label = {
191
+ label: _COLORS[index % len(_COLORS)]
192
+ for index, label in enumerate(labels_in_order)
193
+ }
194
+
195
+ fig = Figure(figsize=(4 * n_cols + 2, 3 * n_rows + 1))
196
+ fig.set_layout_engine("constrained")
197
+ axes = fig.subplots(n_rows, n_cols, squeeze=False, sharex=True, sharey=True)
198
+
199
+ handles_by_label: dict[str, Line2D] = {}
200
+ for config, (row_value, col_value) in zip(configurations, cells, strict=True):
201
+ ax = _cell(axes, row_values.index(row_value), col_values.index(col_value))
202
+
203
+ points = configuration_points(config, percentile=percentile)
204
+ xs = [x for x, _ in points]
205
+ ys = [y for _, y in points]
206
+
207
+ (line,) = ax.plot(
208
+ xs,
209
+ ys,
210
+ label=config.label,
211
+ marker=marker_by_label[config.label],
212
+ color=color_by_label[config.label],
213
+ )
214
+ handles_by_label.setdefault(config.label, line)
215
+
216
+ if log_x:
217
+ ax.set_xscale("log")
218
+ ax.grid(True)
219
+
220
+ # Label each row/column of the grid with its split-key value (the key name
221
+ # itself is carried by the axis titles below).
222
+ for row_index, row_value in enumerate(row_values):
223
+ if row_value is not None:
224
+ _cell(axes, row_index, 0).set_ylabel(row_value)
225
+ for col_index, col_value in enumerate(col_values):
226
+ if col_value is not None:
227
+ _cell(axes, 0, col_index).set_title(col_value)
228
+
229
+ score_label = "Mean" if percentile is None else f"{percentile:g}th Percentile"
230
+ fig.supxlabel("Bit Error Rate")
231
+ fig.supylabel(f"{score_label} {metric.score_name()} [%]")
232
+
233
+ fig.legend(
234
+ handles_by_label.values(),
235
+ handles_by_label.keys(),
236
+ loc="outside upper left",
237
+ frameon=False,
238
+ ncols=2,
239
+ )
240
+
241
+ return fig
242
+
243
+
244
+ def _residual_faults(run_bitmask: Sequence[int]) -> int:
245
+ """Total bits that differ from golden post-decode, across one run.
246
+
247
+ The count of bits actually flipped after decoding, which can be less than
248
+ the injected fault count if the encoding masked some of them.
249
+ """
250
+ return sum(value.bit_count() for value in run_bitmask)
251
+
252
+
253
+ def _histogram_range(values: Sequence[float]) -> tuple[float, float]:
254
+ """A valid (non-zero-width) range for `numpy.histogram2d`."""
255
+ low = min(values)
256
+ high = max(values)
257
+ return (low, high) if low != high else (low, low + 1)
258
+
259
+
260
+ def _colorbar_ticks(vmax: float) -> list[int]:
261
+ """Positive integer occurrence counts up to `vmax`, in the standard
262
+ "1-2-5" preferred-number sequence (1, 2, 5, 10, 20, 50, ...) - the same
263
+ convention `LogLocator(subs=[1, 2, 5])` uses for log-scaled axes.
264
+
265
+ Explicit rather than left to `LogNorm`'s default locator: occurrence
266
+ counts are always positive integers, but when a panel's real range is
267
+ narrow (e.g. every visible cell has exactly one occurrence), that locator
268
+ invents fractional, decade-boundary ticks outside the real data (like
269
+ `10^-1`, i.e. 0.1 occurrences) to pad out a nonsingular range - which
270
+ reads as a negative value and doesn't correspond to anything real.
271
+ """
272
+ ticks: list[int] = []
273
+ magnitude = 1
274
+ while magnitude <= vmax:
275
+ for multiplier in (1, 2, 5):
276
+ tick = magnitude * multiplier
277
+ if tick <= vmax:
278
+ ticks.append(tick)
279
+ magnitude *= 10
280
+ return ticks if ticks else [1]
281
+
282
+
283
+ def build_heatmap_figure(
284
+ results: Sequence[SavedResult],
285
+ *,
286
+ bins: int = 50,
287
+ min_score: float | None = None,
288
+ max_score: float | None = None,
289
+ max_total_faults: int | None = None,
290
+ skip_multi_bit_faults: bool = False,
291
+ ) -> Figure:
292
+ """A figure of per-run score density against faulty bit position (built
293
+ from `bit_position_histogram`) - shows which bit positions correlate with
294
+ reliability loss.
295
+
296
+ Bins with no data are filled with the colormap's minimum color rather
297
+ than left transparent - `LogNorm` treats a zero count as invalid and
298
+ masks it, which would otherwise show through as plain white.
299
+
300
+ Raises:
301
+ ValueError: If `results` is empty, any entry isn't a `DetailedResult`
302
+ (a heatmap fundamentally needs per-run `bitmask` data), they don't
303
+ all share a `dtype`/`reliability_metric` (both affect axis
304
+ semantics/bounds, not just cosmetics), or no runs remain after
305
+ filtering.
306
+ """
307
+ if not results:
308
+ raise ValueError("no results to plot")
309
+
310
+ for result in results:
311
+ if not isinstance(result.result, DetailedResult):
312
+ raise ValueError(
313
+ "heatmap requires results recorded with --compare-bitwise "
314
+ "(a per-run bitmask)"
315
+ )
316
+
317
+ metrics = {result.reliability_metric() for result in results}
318
+ if len(metrics) > 1:
319
+ names = sorted(metric.value for metric in metrics)
320
+ raise ValueError(f"all results must share a reliability metric, got {names}")
321
+ metric = next(iter(metrics))
322
+
323
+ dtypes = {result.fingerprint.scalars["dtype"] for result in results}
324
+ if len(dtypes) > 1:
325
+ raise ValueError(f"all results must share a dtype, got {sorted(dtypes)}")
326
+ bit_width = EncodingDtype(next(iter(dtypes))).bit_count()
327
+
328
+ scores: list[float] = []
329
+ positions: list[int] = []
330
+ weights: list[int] = []
331
+
332
+ for result in results:
333
+ assert isinstance(result.result, DetailedResult)
334
+ for run, score in zip(result.result.results, result.scores(), strict=True):
335
+ if min_score is not None and score < min_score:
336
+ continue
337
+ if max_score is not None and score > max_score:
338
+ continue
339
+
340
+ if max_total_faults is not None:
341
+ residual = _residual_faults(run.bitmask)
342
+ if residual > max_total_faults:
343
+ continue
344
+
345
+ histogram = bit_position_histogram(
346
+ run.bitmask, skip_multi_bit=skip_multi_bit_faults
347
+ )
348
+ for position, count in histogram.items():
349
+ scores.append(score)
350
+ positions.append(position)
351
+ weights.append(count)
352
+
353
+ if not scores:
354
+ raise ValueError("no runs left after filtering")
355
+
356
+ score_range = _histogram_range(scores)
357
+ x_edges = np.histogram_bin_edges([], bins=bins, range=score_range)
358
+
359
+ counts, _, y_edges = np.histogram2d(
360
+ scores,
361
+ positions,
362
+ bins=[x_edges, bit_width],
363
+ range=[score_range, (-0.5, bit_width - 0.5)],
364
+ weights=weights,
365
+ )
366
+
367
+ cmap = matplotlib.colormaps["plasma"]
368
+ background = cmap(0.0)
369
+ # `vmin` is deliberately below the smallest real occurrence count (1),
370
+ # not equal to it - `LogNorm` maps `vmin` itself to the very bottom of
371
+ # the colormap, which is also `background` above; if a real count of 1
372
+ # used exactly `vmin`, it would render identically to an empty cell.
373
+ norm = LogNorm(vmin=0.5, vmax=counts.max())
374
+
375
+ fig = Figure(figsize=(8, 5))
376
+ fig.set_layout_engine("constrained")
377
+ mosaic = fig.subplot_mosaic([["main", "cbar"]], width_ratios=[0.95, 0.05])
378
+ ax = mosaic["main"]
379
+ colorbar_ax = mosaic["cbar"]
380
+ assert isinstance(ax, Axes)
381
+ assert isinstance(colorbar_ax, Axes)
382
+ ax.set_facecolor(background)
383
+
384
+ image = ax.pcolormesh(x_edges, y_edges, counts.T, cmap=cmap, norm=norm)
385
+ ax.set_xlabel(f"{metric.score_name()} [%]")
386
+ ax.set_ylabel("Bit Position")
387
+
388
+ colorbar = fig.colorbar(image, cax=colorbar_ax, ticks=_colorbar_ticks(counts.max()))
389
+ # `LogNorm` colorbars auto-attach sub-decade minor ticks (e.g. `6x10^-1`)
390
+ # independent of the `ticks=` override above - turn them off so only the
391
+ # explicit, always-meaningful integer ticks show.
392
+ colorbar.minorticks_off()
393
+ colorbar_ax.set_ylabel("Occurrences")
394
+
395
+ return fig
@@ -0,0 +1,212 @@
1
+ """Turning saved `encoded_memory` result files into comparable configurations.
2
+
3
+ This is the data-wrangling half of the `compare`/`heatmap` commands (the
4
+ figure-drawing half lives in `plots`). It has no `matplotlib` dependency, so
5
+ it can be exercised without a plotting backend.
6
+
7
+ Vocabulary used across this module and `plots`:
8
+
9
+ - **configuration** - a set of loaded results that are identical in every
10
+ fingerprint field *except* the injected fault count. They only differ in bit
11
+ error rate, so they describe "the same thing measured at several rates" and
12
+ are drawn as a single line. This is the unit `compare` compares. Grouping is
13
+ driven entirely by fingerprint equality, never by how files are laid out on
14
+ disk.
15
+ - **bit error rate** - `faults / total_bits`; the x-axis of `compare`. The one
16
+ quantity allowed to vary within a configuration.
17
+ - **split key** (`plots.GroupBy`) - a separate concept: a fingerprint-derived
18
+ key (dtype, model, ...) that `compare` uses to fan a single chart out into a
19
+ *grid* of rows and/or columns via `row_by`/`col_by`. Splitting the grid is
20
+ unrelated to which configuration a result belongs to.
21
+
22
+ The `compare` pipeline, end to end:
23
+
24
+ 1. `commands.compare` parses each path argument into a path plus an optional
25
+ `=LABEL` override.
26
+ 2. `load_results` discovers and loads every file under those paths into one
27
+ flat pool of `(path, result)` pairs, skipping anything that fails to load.
28
+ 3. `build_configurations` clusters that pool into `Configuration`s and names
29
+ each one.
30
+ 4. `plots.build_compare_figure` places each configuration in its grid cell and
31
+ draws its line.
32
+ """
33
+
34
+ import logging
35
+ from collections.abc import Mapping, Sequence
36
+ from dataclasses import dataclass
37
+ from pathlib import Path
38
+
39
+ import numpy as np
40
+ from faultforge import Fingerprint
41
+ from faultforge.experiments.encoded_memory import SavedResult
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ def discover_result_files(path: Path) -> list[Path]:
47
+ """Resolve `path` to the result file(s) it names.
48
+
49
+ A file resolves to itself; a directory resolves to every regular file
50
+ found recursively within it, sorted for determinism.
51
+ """
52
+ resolved = path.expanduser()
53
+ if resolved.is_dir():
54
+ return sorted(p for p in resolved.rglob("*") if p.is_file())
55
+ return [resolved]
56
+
57
+
58
+ def load_results(paths: Sequence[Path]) -> list[tuple[Path, SavedResult]]:
59
+ """Step 2 of the `compare` pipeline: load every result file found across
60
+ all of `paths` (recursing into any directories) into one flat pool of
61
+ `(path, result)` pairs.
62
+
63
+ Skips (with a `logger.warning`) any file that isn't a valid saved
64
+ result - e.g. a stray non-result file living alongside real ones in a
65
+ directory - rather than failing the whole load.
66
+ """
67
+ loaded: list[tuple[Path, SavedResult]] = []
68
+ for path in paths:
69
+ for file in discover_result_files(path):
70
+ try:
71
+ loaded.append((file, SavedResult.load(file)))
72
+ except Exception as error:
73
+ logger.warning(
74
+ f"Failed to load a result from {file} - skipping\n-> {error}"
75
+ )
76
+ return loaded
77
+
78
+
79
+ _VARYING_SCALAR = "faults"
80
+ """The one `Fingerprint` scalar clustering ignores: it's expected to vary
81
+ within a configuration, since it's the x-axis being compared (bit error rate)."""
82
+
83
+
84
+ @dataclass(slots=True, frozen=True)
85
+ class Configuration:
86
+ """One line on a `compare` chart: results that match on everything except
87
+ bit error rate, plus the legend label to draw them under.
88
+ """
89
+
90
+ label: str
91
+ results: list[tuple[Path, SavedResult]]
92
+
93
+
94
+ def _fingerprint_without_faults(fingerprint: Fingerprint) -> Fingerprint:
95
+ return fingerprint.model_copy(
96
+ update={
97
+ "scalars": {
98
+ key: value
99
+ for key, value in fingerprint.scalars.items()
100
+ if key != _VARYING_SCALAR
101
+ }
102
+ }
103
+ )
104
+
105
+
106
+ def _cluster_by_fingerprint(
107
+ loaded: Sequence[tuple[Path, SavedResult]],
108
+ ) -> list[list[tuple[Path, SavedResult]]]:
109
+ """Group entries whose fingerprints agree on everything except `faults`.
110
+
111
+ Driven entirely by `Fingerprint` structural equality (via
112
+ `Fingerprint.diff`), not by how the caller organized files on disk.
113
+ """
114
+ clusters: list[tuple[Fingerprint, list[tuple[Path, SavedResult]]]] = []
115
+ for path, result in loaded:
116
+ key = _fingerprint_without_faults(result.fingerprint)
117
+ for representative, entries in clusters:
118
+ if not representative.diff(key):
119
+ entries.append((path, result))
120
+ break
121
+ else:
122
+ clusters.append((key, [(path, result)]))
123
+ return [entries for _, entries in clusters]
124
+
125
+
126
+ def build_configurations(
127
+ loaded: Sequence[tuple[Path, SavedResult]],
128
+ *,
129
+ label_overrides: Mapping[Path, str],
130
+ ) -> list[Configuration]:
131
+ """Step 3 of the `compare` pipeline: cluster `loaded` into configurations
132
+ and label each one.
133
+
134
+ A configuration's label is whichever `label_overrides` entry applies to
135
+ one of its files - warning (and keeping the first one seen) if more than
136
+ one distinct override applies to the same configuration, since that means
137
+ the caller gave conflicting names to what turned out to be one line. Falls
138
+ back to the stem of the configuration's first (sorted) file when no
139
+ override applies.
140
+ """
141
+ configurations: list[Configuration] = []
142
+ for cluster in _cluster_by_fingerprint(loaded):
143
+ cluster_sorted = sorted(cluster, key=lambda entry: entry[0])
144
+
145
+ label: str | None = None
146
+ for path, _ in cluster_sorted:
147
+ override = label_overrides.get(path)
148
+ if override is None:
149
+ continue
150
+ if label is not None and label != override:
151
+ logger.warning(
152
+ f"conflicting labels {label!r} and {override!r} apply to "
153
+ "the same configuration; keeping the first"
154
+ )
155
+ continue
156
+ label = override
157
+
158
+ if label is None:
159
+ label = cluster_sorted[0][0].stem
160
+
161
+ configurations.append(Configuration(label=label, results=cluster_sorted))
162
+ return configurations
163
+
164
+
165
+ def configuration_points(
166
+ configuration: Configuration, *, percentile: float | None
167
+ ) -> list[tuple[float, float]]:
168
+ """Flatten every run across every file in `configuration` into
169
+ `(bit_error_rate, score)` points, one per distinct bit error rate.
170
+
171
+ Runs recorded at the same bit error rate (whether from the same file or
172
+ different files within the configuration) are pooled and reduced to
173
+ `percentile` (or the mean, if `None`). Sorted by bit error rate.
174
+ """
175
+ scores_by_rate: dict[float, list[float]] = {}
176
+ for _, result in configuration.results:
177
+ rate = result.bit_error_rate()
178
+ scores_by_rate.setdefault(rate, []).extend(result.scores())
179
+
180
+ def reduce(scores: list[float]) -> float:
181
+ if percentile is None:
182
+ return float(np.mean(scores))
183
+ return float(np.percentile(scores, percentile))
184
+
185
+ return sorted((rate, reduce(scores)) for rate, scores in scores_by_rate.items())
186
+
187
+
188
+ def bit_position_histogram(
189
+ bitmask: Sequence[int], *, skip_multi_bit: bool = False
190
+ ) -> dict[int, int]:
191
+ """Count how many `bitmask` elements had each bit position flipped.
192
+
193
+ Maps bit position (0-indexed from the LSB) to how many elements had that
194
+ position differ from the golden value, by decomposing each stored xor value
195
+ bit-by-bit. This is the "which bit index gets hit" view the `heatmap`
196
+ command's bottom panel is built from.
197
+
198
+ Pass `skip_multi_bit=True` to exclude elements with more than one faulty
199
+ bit, isolating cases where a single bit flip's position is unambiguous.
200
+ """
201
+ histogram: dict[int, int] = {}
202
+ for value in bitmask:
203
+ if skip_multi_bit and value.bit_count() > 1:
204
+ continue
205
+ position = 0
206
+ remaining = value
207
+ while remaining:
208
+ if remaining & 1:
209
+ histogram[position] = histogram.get(position, 0) + 1
210
+ remaining >>= 1
211
+ position += 1
212
+ return histogram