Skip to content

Commit 8d6392b

Browse files
feguestefantaubert
andauthored
Upcast timing columns to float32 in Arrow export to avoid halffloat interop issues (#43)
* Upcast timing columns to float32 in Arrow export to avoid halffloat interop issues * add fix for issue 38 * add comment for current limitation --------- Co-authored-by: Stefan Taubert <23339395+stefantaubert@users.noreply.github.com>
1 parent 2abc558 commit 8d6392b

8 files changed

Lines changed: 426 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Bugfixes
1111

1212
- Fixed acoustic inference session being aborted on macOS when stats were enabled: hardened parent/child memory tracking against `psutil.AccessDenied`, and replaced the two tracked semaphores with a wrapper that mirrors the count into shared memory so `get_value()` works on macOS (#39)
13+
- Fixed float16 quantization of segment timestamps in prediction results, which caused up to ±0.05 s drift in CSV/DataFrame/Parquet output (#38, #42). Also closed an analogous hole in encoding results where a hop duration that is exactly representable in float16 (e.g. hop=1.5) could still produce drifting accumulated timestamps. Timestamps are now always materialized at >= float32 precision at the source.
1314

1415
## [0.2.15] - 2026-05-02
1516

docs/general.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,25 @@ A *Producer* loads only as much audio as the buffer can hold, keeping RAM usage
2626
* **Buffer Size** – By default, the buffer is set to twice the *Worker* count, ensuring that every *Worker* always has a pre-loaded batch to process and thus avoids idle time.
2727
* **Model Backends** – Each worker loads its own instance of the inference model. On the CPU, both **TFLite** and **Protocol Buffers** (Protobuf) models can be used; Protobuf models can optionally run on the GPU.
2828
* **Best Practice for CPU Inference** – For CPU-only execution on Linux, the number of *Worker* processes should not exceed the number of physical cores, as oversubscription typically leads to reduced performance. When running TFLite, keep the batch size to one (1); larger batches offer no throughput benefit.
29+
30+
Known limitations
31+
----
32+
33+
**End-time precision on the last segment of short files (≤ ~34 minutes).**
34+
For memory efficiency, per-file durations are stored in the smallest float
35+
dtype that covers their magnitude: ``float16`` for files up to 2\ :sup:`11` ≈
36+
2048 s, ``float32`` for files up to 2\ :sup:`24` s (~194 days), ``float64``
37+
beyond. The stored duration is used as the upper clamp when computing the
38+
``end_time`` of the *last* segment of each file. Inside the float16 range
39+
this rounding is visible: the largest representable float16 below ``X`` may
40+
differ from ``X`` by up to one ULP — about 0.06 s near 128 s, 0.25 s near
41+
1024 s, and 0.5 s near 2048 s. The error appears only on the very last
42+
segment per file and only when the actual file duration is not exactly
43+
representable in float16 (integer-second durations up to 2048 s are
44+
exact). For files of one hour or longer the storage dtype is float32, where
45+
the equivalent ULP is below 4 ms even at 12 h, so the effect is not
46+
observable in practice.
47+
48+
All other timestamps (``start_time`` and ``end_time`` of every segment that
49+
does not hit the clamp) are computed at ≥ float32 precision regardless of
50+
file length.

src/birdnet/acoustic/inference/core/encoding/encoding_result.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
format_input_for_csv,
2222
get_uint_dtype,
2323
hms_centis_fast,
24-
upgrade_float_dtype_for_value,
2524
)
2625

2726
if TYPE_CHECKING:
@@ -135,11 +134,11 @@ def to_structured_array(self) -> np.ndarray:
135134
embeddings_selected = self.embeddings[valid_file_idx, valid_seg_idx]
136135

137136
hop_duration_s = self.hop_duration_s
138-
# Upgrade the storage dtype for the output if it cannot represent hop
139-
# exactly, otherwise rounding accumulates across segments.
140-
time_dtype = upgrade_float_dtype_for_value(
141-
self._input_durations.dtype, hop_duration_s
142-
)
137+
# Force at least float32 for timing columns. The bulk _input_durations
138+
# array is stored in a magnitude-based dtype (float16 for files <= 2**11 s),
139+
# which is too coarse for accumulated i*hop products and would also produce
140+
# Arrow halffloat that some implementations (e.g. R) cannot read.
141+
time_dtype = np.result_type(self._input_durations.dtype, np.float32)
143142

144143
dtype = [
145144
(VAR_INPUT, self._input_dtype),

src/birdnet/acoustic/inference/core/prediction/prediction_result.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,15 @@ def to_structured_array(self) -> np.ndarray:
153153
del valid_mask
154154

155155
n_predictions = len(valid_indices[0])
156+
# Force at least float32 for timing columns. The bulk _input_durations
157+
# array is stored in a magnitude-based dtype (float16 for files <= 2**11 s),
158+
# which is too coarse for accumulated i*hop products and would also produce
159+
# Arrow halffloat that some implementations (e.g. R) cannot read.
160+
time_dtype = np.result_type(self._input_durations.dtype, np.float32)
156161
dtype = [
157162
(VAR_INPUT, self._input_dtype),
158-
(VAR_START_TIME, self._input_durations.dtype),
159-
(VAR_END_TIME, self._input_durations.dtype),
163+
(VAR_START_TIME, time_dtype),
164+
(VAR_END_TIME, time_dtype),
160165
(VAR_SPECIES_NAME, object),
161166
(VAR_CONFIDENCE, self._species_probs.dtype),
162167
]
@@ -191,15 +196,15 @@ def to_structured_array(self) -> np.ndarray:
191196
del sort_indices
192197

193198
hop_duration_s = self.hop_duration_s
194-
start_times = chunk_idx_flat.astype(self._input_durations.dtype) * hop_duration_s
199+
start_times = chunk_idx_flat.astype(time_dtype) * hop_duration_s
195200
del hop_duration_s
196201
del chunk_idx_flat
197202

198203
structured_array[VAR_START_TIME] = start_times
199204
structured_array[VAR_END_TIME] = np.minimum(
200205
start_times
201206
+ apply_speed_to_duration(self._segment_duration_s[0], self._speed[0]),
202-
self._input_durations[file_idx_flat],
207+
self._input_durations[file_idx_flat].astype(time_dtype),
203208
)
204209
del start_times
205210
structured_array[VAR_INPUT] = self._inputs[file_idx_flat]
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from pathlib import Path
2+
3+
import numpy as np
4+
import pyarrow as pa
5+
import pyarrow.parquet as pq
6+
7+
from birdnet.acoustic.inference.core.encoding.encoding_result import (
8+
AcousticFileEncodingResult,
9+
)
10+
from birdnet_tests.acoustic_models.inference.encoding.encoding_result_py.test_encoding_to_structured_array import ( # noqa: E501
11+
create_file_encoding_result,
12+
)
13+
14+
15+
def _create_result_with_float16_durations() -> AcousticFileEncodingResult:
16+
"""Create an encoding result whose input_durations are float16."""
17+
result = create_file_encoding_result(
18+
n_files=2,
19+
duration_s=12,
20+
segment_duration_s=3.0,
21+
overlap_duration_s=0.0,
22+
)
23+
assert result.input_durations.dtype == np.float16
24+
return result
25+
26+
27+
def _create_result_with_float32_durations() -> AcousticFileEncodingResult:
28+
"""Create an encoding result whose input_durations are float32."""
29+
result = create_file_encoding_result(
30+
n_files=1,
31+
duration_s=5000,
32+
segment_duration_s=3.0,
33+
overlap_duration_s=0.0,
34+
)
35+
assert result.input_durations.dtype == np.float32
36+
return result
37+
38+
39+
def _create_result_with_float64_durations() -> AcousticFileEncodingResult:
40+
"""Create an encoding result whose input_durations are float64.
41+
42+
Uses a small duration for speed, then coerces dtype to float64 to exercise
43+
the Arrow type-promotion path without creating millions of segments.
44+
"""
45+
result = create_file_encoding_result(
46+
n_files=1,
47+
duration_s=12,
48+
segment_duration_s=3.0,
49+
overlap_duration_s=0.0,
50+
)
51+
result._input_durations = result._input_durations.astype(np.float64)
52+
assert result.input_durations.dtype == np.float64
53+
return result
54+
55+
56+
def test_arrow_table_time_columns_are_float32_when_durations_float16() -> None:
57+
result = _create_result_with_float16_durations()
58+
table = result.to_arrow_table()
59+
60+
assert table.schema.field("start_time").type == pa.float32()
61+
assert table.schema.field("end_time").type == pa.float32()
62+
63+
64+
def test_arrow_table_time_columns_are_float32_when_durations_float32() -> None:
65+
result = _create_result_with_float32_durations()
66+
table = result.to_arrow_table()
67+
68+
assert table.schema.field("start_time").type == pa.float32()
69+
assert table.schema.field("end_time").type == pa.float32()
70+
71+
72+
def test_arrow_table_time_columns_are_float64_when_durations_float64() -> None:
73+
result = _create_result_with_float64_durations()
74+
table = result.to_arrow_table()
75+
76+
assert table.schema.field("start_time").type == pa.float64()
77+
assert table.schema.field("end_time").type == pa.float64()
78+
79+
80+
def test_parquet_roundtrip_schema_float16(tmp_path: Path) -> None:
81+
result = _create_result_with_float16_durations()
82+
out = tmp_path / "result.parquet"
83+
84+
result.to_parquet(out, silent=True)
85+
table = pq.read_table(out)
86+
87+
assert table.schema.field("start_time").type == pa.float32()
88+
assert table.schema.field("end_time").type == pa.float32()
89+
90+
91+
def test_parquet_roundtrip_values_float16(tmp_path: Path) -> None:
92+
result = _create_result_with_float16_durations()
93+
structured = result.to_structured_array()
94+
out = tmp_path / "result.parquet"
95+
96+
expected_start = np.array(structured["start_time"], dtype=np.float64)
97+
expected_end = np.array(structured["end_time"], dtype=np.float64)
98+
99+
result.to_parquet(out, silent=True)
100+
table = pq.read_table(out)
101+
102+
actual_start = np.array(table.column("start_time").to_pylist(), dtype=np.float64)
103+
actual_end = np.array(table.column("end_time").to_pylist(), dtype=np.float64)
104+
105+
np.testing.assert_allclose(expected_start, actual_start, rtol=1e-3)
106+
np.testing.assert_allclose(expected_end, actual_end, rtol=1e-3)
107+
108+
109+
def test_parquet_roundtrip_values_float32(tmp_path: Path) -> None:
110+
result = _create_result_with_float32_durations()
111+
structured = result.to_structured_array()
112+
out = tmp_path / "result.parquet"
113+
114+
expected_start = np.array(structured["start_time"], dtype=np.float64)
115+
expected_end = np.array(structured["end_time"], dtype=np.float64)
116+
117+
result.to_parquet(out, silent=True)
118+
table = pq.read_table(out)
119+
120+
actual_start = np.array(table.column("start_time").to_pylist(), dtype=np.float64)
121+
actual_end = np.array(table.column("end_time").to_pylist(), dtype=np.float64)
122+
123+
np.testing.assert_allclose(expected_start, actual_start, rtol=1e-6)
124+
np.testing.assert_allclose(expected_end, actual_end, rtol=1e-6)
125+
126+
127+
def test_parquet_roundtrip_values_float64(tmp_path: Path) -> None:
128+
result = _create_result_with_float64_durations()
129+
structured = result.to_structured_array()
130+
out = tmp_path / "result.parquet"
131+
132+
expected_start = np.array(structured["start_time"], dtype=np.float64)
133+
expected_end = np.array(structured["end_time"], dtype=np.float64)
134+
135+
result.to_parquet(out, silent=True)
136+
table = pq.read_table(out)
137+
138+
actual_start = np.array(table.column("start_time").to_pylist(), dtype=np.float64)
139+
actual_end = np.array(table.column("end_time").to_pylist(), dtype=np.float64)
140+
141+
np.testing.assert_allclose(expected_start, actual_start, rtol=1e-9)
142+
np.testing.assert_allclose(expected_end, actual_end, rtol=1e-9)
143+
144+
145+
def test_parquet_time_columns_no_halffloat(tmp_path: Path) -> None:
146+
"""Ensure start_time and end_time never use halffloat in Parquet."""
147+
result = _create_result_with_float16_durations()
148+
out = tmp_path / "result.parquet"
149+
150+
result.to_parquet(out, silent=True)
151+
table = pq.read_table(out)
152+
153+
for col_name in ("start_time", "end_time"):
154+
field = table.schema.field(col_name)
155+
assert field.type != pa.float16(), (
156+
f"Column '{col_name}' uses halffloat (float16), "
157+
f"which is not interoperable across Arrow implementations"
158+
)

src/birdnet_tests/acoustic_models/inference/encoding/encoding_result_py/test_encoding_to_structured_array.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,38 @@ def test_dtype_structure() -> None:
366366
"embedding",
367367
)
368368
assert structured.dtype["input"] == np.dtype("O")
369-
assert structured.dtype["start_time"] == result._input_durations.dtype
370-
assert structured.dtype["end_time"] == result._input_durations.dtype
369+
expected_time_dtype = np.result_type(result._input_durations.dtype, np.float32)
370+
assert structured.dtype["start_time"] == expected_time_dtype
371+
assert structured.dtype["end_time"] == expected_time_dtype
371372
assert embedding_dtype.shape == (DEFAULT_EMBEDDING_DIM,)
372373
assert embedding_dtype.base == np.dtype(np.float32)
374+
375+
376+
def test_time_calculations_hop_exact_in_float16_but_products_drift() -> None:
377+
# hop=1.5 is exactly representable in float16, but i*1.5 in the 1024..2048
378+
# range only has step size 1, so e.g. 1365*1.5 = 2047.5 rounds. The previous
379+
# upgrade_float_dtype_for_value heuristic only checked the scalar hop and
380+
# missed this; the source fix forces >= float32 unconditionally.
381+
duration = 1500.0
382+
segment_duration = 3.0
383+
overlap_duration = 1.5
384+
speed = 1.0
385+
result = create_file_encoding_result(
386+
n_files=1,
387+
duration_s=duration,
388+
segment_duration_s=segment_duration,
389+
overlap_duration_s=overlap_duration,
390+
speed=speed,
391+
)
392+
393+
structured = result.to_structured_array()
394+
hop = get_hop_duration_s(segment_duration, overlap_duration, speed)
395+
expected_starts = np.arange(len(structured)) * hop
396+
expected_ends = np.minimum(
397+
expected_starts + segment_duration * speed, result.input_durations[0]
398+
)
399+
400+
np.testing.assert_allclose(structured["start_time"], expected_starts)
401+
np.testing.assert_allclose(structured["end_time"], expected_ends)
402+
assert structured.dtype["start_time"] != np.float16
403+
assert structured.dtype["end_time"] != np.float16

src/birdnet_tests/acoustic_models/inference/predictions/prediction_result_py/test_prediction_to_structured_array.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from birdnet.model_loader import load
1515
from birdnet.utils.helper import (
1616
get_float_dtype,
17+
get_hop_duration_s,
1718
get_n_segments_speed,
1819
)
1920
from birdnet_tests.test_files import TEST_FILE_LONG
@@ -481,8 +482,9 @@ def test_dtype_structure() -> None:
481482
]
482483
assert structured.dtype.names == tuple(expected_fields)
483484
assert structured.dtype["input"] == np.dtype("O")
484-
assert structured.dtype["start_time"] == result._input_durations.dtype
485-
assert structured.dtype["end_time"] == result._input_durations.dtype
485+
expected_time_dtype = np.result_type(result._input_durations.dtype, np.float32)
486+
assert structured.dtype["start_time"] == expected_time_dtype
487+
assert structured.dtype["end_time"] == expected_time_dtype
486488
assert structured.dtype["species_name"] == np.dtype("O")
487489
assert structured.dtype["confidence"] == result._species_probs.dtype
488490

@@ -525,3 +527,33 @@ def test_full_pipeline_np() -> None:
525527
res = session.run_arrays(sf_read)
526528
structured = res.to_structured_array()
527529
assert len(structured) == 80
530+
531+
532+
def test_time_calculations_issue_38_long_file_with_overlap_and_slowdown() -> None:
533+
# Reproduces issue #38: a 120s file with speed=0.3 and overlap=0.7 forces
534+
# the float16 input_durations dtype, and accumulated i*hop products were
535+
# quantized into ~0.05s drift on later segments before the source-level fix.
536+
duration = 120.0
537+
segment_duration = 3.0
538+
overlap_duration = 0.7
539+
speed = 0.3
540+
result = create_file_prediction_result(
541+
n_files=1,
542+
duration_s=duration,
543+
top_k=1,
544+
segment_duration_s=segment_duration,
545+
overlap_duration_s=overlap_duration,
546+
speed=speed,
547+
)
548+
549+
structured = result.to_structured_array()
550+
hop = get_hop_duration_s(segment_duration, overlap_duration, speed)
551+
expected_starts = np.arange(len(structured)) * hop
552+
expected_ends = np.minimum(
553+
expected_starts + segment_duration * speed, result.input_durations[0]
554+
)
555+
556+
np.testing.assert_allclose(structured["start_time"], expected_starts)
557+
np.testing.assert_allclose(structured["end_time"], expected_ends)
558+
assert structured.dtype["start_time"] != np.float16
559+
assert structured.dtype["end_time"] != np.float16

0 commit comments

Comments
 (0)