Skip to content

Commit 322b493

Browse files
committed
Upcast timing columns to float32 in Arrow export to avoid halffloat interop issues
1 parent 85f788b commit 322b493

4 files changed

Lines changed: 338 additions & 7 deletions

File tree

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,17 @@ def to_arrow_table(self) -> pa.Table:
199199

200200
arrow_arrays: dict[str, pa.Array] = {}
201201
arrow_arrays[VAR_INPUT] = pa.array(structured[VAR_INPUT]).dictionary_encode()
202+
# Use at least float32 for timing columns to avoid Arrow halffloat,
203+
# which is not interoperable across all Arrow implementations (e.g. R).
204+
time_np_dtype = np.result_type(structured[VAR_START_TIME].dtype, np.float32)
205+
time_type = pa.from_numpy_dtype(time_np_dtype)
202206
arrow_arrays[VAR_START_TIME] = pa.array(
203-
structured[VAR_START_TIME],
204-
type=pa.from_numpy_dtype(structured[VAR_START_TIME].dtype),
207+
structured[VAR_START_TIME].astype(time_np_dtype),
208+
type=time_type,
205209
)
206210
arrow_arrays[VAR_END_TIME] = pa.array(
207-
structured[VAR_END_TIME],
208-
type=pa.from_numpy_dtype(structured[VAR_END_TIME].dtype),
211+
structured[VAR_END_TIME].astype(time_np_dtype),
212+
type=time_type,
209213
)
210214

211215
embedding_element_type = pa.from_numpy_dtype(self._embeddings.dtype)

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,17 @@ def to_arrow_table(self) -> pa.Table:
219219

220220
arrow_arrays: dict[str, pa.Array] = {}
221221
arrow_arrays[VAR_INPUT] = pa.array(structured[VAR_INPUT]).dictionary_encode()
222+
# Use at least float32 for timing columns to avoid Arrow halffloat,
223+
# which is not interoperable across all Arrow implementations (e.g. R).
224+
time_np_dtype = np.result_type(structured[VAR_START_TIME].dtype, np.float32)
225+
time_type = pa.from_numpy_dtype(time_np_dtype)
222226
arrow_arrays[VAR_START_TIME] = pa.array(
223-
structured[VAR_START_TIME],
224-
type=pa.from_numpy_dtype(structured[VAR_START_TIME].dtype),
227+
structured[VAR_START_TIME].astype(time_np_dtype),
228+
type=time_type,
225229
)
226230
arrow_arrays[VAR_END_TIME] = pa.array(
227-
structured[VAR_END_TIME], type=pa.from_numpy_dtype(structured[VAR_END_TIME].dtype)
231+
structured[VAR_END_TIME].astype(time_np_dtype),
232+
type=time_type,
228233
)
229234
arrow_arrays[VAR_SPECIES_NAME] = pa.array(
230235
structured[VAR_SPECIES_NAME]
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+
)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
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.prediction.prediction_result import (
8+
AcousticFilePredictionResult,
9+
)
10+
from birdnet_tests.acoustic_models.inference.predictions.prediction_result_py.test_prediction_to_structured_array import ( # noqa: E501
11+
create_file_prediction_result,
12+
)
13+
14+
15+
def _create_result_with_float16_durations() -> AcousticFilePredictionResult:
16+
"""Create a prediction result whose input_durations are float16."""
17+
result = create_file_prediction_result(
18+
n_files=2,
19+
duration_s=12,
20+
top_k=3,
21+
segment_duration_s=3.0,
22+
overlap_duration_s=0.0,
23+
)
24+
assert result.input_durations.dtype == np.float16
25+
return result
26+
27+
28+
def _create_result_with_float32_durations() -> AcousticFilePredictionResult:
29+
"""Create a prediction result whose input_durations are float32."""
30+
result = create_file_prediction_result(
31+
n_files=1,
32+
duration_s=5000,
33+
top_k=1,
34+
segment_duration_s=3.0,
35+
overlap_duration_s=0.0,
36+
)
37+
assert result.input_durations.dtype == np.float32
38+
return result
39+
40+
41+
def _create_result_with_float64_durations() -> AcousticFilePredictionResult:
42+
"""Create a prediction result whose input_durations are float64.
43+
44+
Uses a small duration for speed, then coerces dtype to float64 to exercise
45+
the Arrow type-promotion path without creating millions of segments.
46+
"""
47+
result = create_file_prediction_result(
48+
n_files=1,
49+
duration_s=12,
50+
top_k=1,
51+
segment_duration_s=3.0,
52+
overlap_duration_s=0.0,
53+
)
54+
result._input_durations = result._input_durations.astype(np.float64)
55+
assert result.input_durations.dtype == np.float64
56+
return result
57+
58+
59+
def test_arrow_table_time_columns_are_float32_when_durations_float16() -> None:
60+
result = _create_result_with_float16_durations()
61+
table = result.to_arrow_table()
62+
63+
assert table.schema.field("start_time").type == pa.float32()
64+
assert table.schema.field("end_time").type == pa.float32()
65+
66+
67+
def test_arrow_table_time_columns_are_float32_when_durations_float32() -> None:
68+
result = _create_result_with_float32_durations()
69+
table = result.to_arrow_table()
70+
71+
assert table.schema.field("start_time").type == pa.float32()
72+
assert table.schema.field("end_time").type == pa.float32()
73+
74+
75+
def test_arrow_table_time_columns_are_float64_when_durations_float64() -> None:
76+
result = _create_result_with_float64_durations()
77+
table = result.to_arrow_table()
78+
79+
assert table.schema.field("start_time").type == pa.float64()
80+
assert table.schema.field("end_time").type == pa.float64()
81+
82+
83+
def test_parquet_roundtrip_schema_float16(tmp_path: Path) -> None:
84+
result = _create_result_with_float16_durations()
85+
out = tmp_path / "result.parquet"
86+
87+
result.to_parquet(out, silent=True)
88+
table = pq.read_table(out)
89+
90+
assert table.schema.field("start_time").type == pa.float32()
91+
assert table.schema.field("end_time").type == pa.float32()
92+
93+
94+
def test_parquet_roundtrip_values_float16(tmp_path: Path) -> None:
95+
result = _create_result_with_float16_durations()
96+
structured = result.to_structured_array()
97+
out = tmp_path / "result.parquet"
98+
99+
expected_start = np.array(structured["start_time"], dtype=np.float64)
100+
expected_end = np.array(structured["end_time"], dtype=np.float64)
101+
expected_conf = np.array(structured["confidence"], dtype=np.float64)
102+
103+
result.to_parquet(out, silent=True)
104+
table = pq.read_table(out)
105+
106+
actual_start = np.array(table.column("start_time").to_pylist(), dtype=np.float64)
107+
actual_end = np.array(table.column("end_time").to_pylist(), dtype=np.float64)
108+
actual_conf = np.array(table.column("confidence").to_pylist(), dtype=np.float64)
109+
110+
np.testing.assert_allclose(expected_start, actual_start, rtol=1e-3)
111+
np.testing.assert_allclose(expected_end, actual_end, rtol=1e-3)
112+
np.testing.assert_allclose(expected_conf, actual_conf, rtol=1e-3)
113+
114+
115+
def test_parquet_roundtrip_values_float32(tmp_path: Path) -> None:
116+
result = _create_result_with_float32_durations()
117+
structured = result.to_structured_array()
118+
out = tmp_path / "result.parquet"
119+
120+
expected_start = np.array(structured["start_time"], dtype=np.float64)
121+
expected_end = np.array(structured["end_time"], dtype=np.float64)
122+
123+
result.to_parquet(out, silent=True)
124+
table = pq.read_table(out)
125+
126+
actual_start = np.array(table.column("start_time").to_pylist(), dtype=np.float64)
127+
actual_end = np.array(table.column("end_time").to_pylist(), dtype=np.float64)
128+
129+
np.testing.assert_allclose(expected_start, actual_start, rtol=1e-6)
130+
np.testing.assert_allclose(expected_end, actual_end, rtol=1e-6)
131+
132+
133+
def test_parquet_roundtrip_values_float64(tmp_path: Path) -> None:
134+
result = _create_result_with_float64_durations()
135+
structured = result.to_structured_array()
136+
out = tmp_path / "result.parquet"
137+
138+
expected_start = np.array(structured["start_time"], dtype=np.float64)
139+
expected_end = np.array(structured["end_time"], dtype=np.float64)
140+
141+
result.to_parquet(out, silent=True)
142+
table = pq.read_table(out)
143+
144+
actual_start = np.array(table.column("start_time").to_pylist(), dtype=np.float64)
145+
actual_end = np.array(table.column("end_time").to_pylist(), dtype=np.float64)
146+
147+
np.testing.assert_allclose(expected_start, actual_start, rtol=1e-9)
148+
np.testing.assert_allclose(expected_end, actual_end, rtol=1e-9)
149+
150+
151+
def test_parquet_time_columns_no_halffloat(tmp_path: Path) -> None:
152+
"""Ensure start_time and end_time never use halffloat in Parquet."""
153+
result = _create_result_with_float16_durations()
154+
out = tmp_path / "result.parquet"
155+
156+
result.to_parquet(out, silent=True)
157+
table = pq.read_table(out)
158+
159+
for col_name in ("start_time", "end_time"):
160+
field = table.schema.field(col_name)
161+
assert field.type != pa.float16(), (
162+
f"Column '{col_name}' uses halffloat (float16), "
163+
f"which is not interoperable across Arrow implementations"
164+
)

0 commit comments

Comments
 (0)