Skip to content

Commit 6598473

Browse files
authored
Merge pull request #2945 from mabel-dev/#2944
Invalid Shape Error
2 parents 1b2013a + 44cbc23 commit 6598473

17 files changed

Lines changed: 1854 additions & 30 deletions

File tree

opteryx/__version__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# THIS FILE IS AUTOMATICALLY UPDATED DURING THE BUILD PROCESS
22
# DO NOT EDIT THIS FILE DIRECTLY
33

4-
__build__ = 1895
4+
__build__ = 1902
55
__author__ = "@joocer"
6-
__version__ = "0.26.2-beta.1895"
6+
__version__ = "0.26.2-beta.1902"
77

88
# Store the version here so:
99
# 1) we don't load dependencies by storing it in __init__.py
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
"""
2+
Draken-based group-by prototype for simple aggregates.
3+
4+
This module implements a Python prototype using Draken's Morsel hashing
5+
and the Abseil FlatHashMap to group rows quickly by hash-based buckets.
6+
7+
It supports simple aggregation functions: COUNT, SUM, MIN, MAX, COUNT_DISTINCT, AVG.
8+
9+
Note: This prototype lives in Python for iteration speed and leverages existing
10+
compiled helpers like `count_distinct` where helpful. For production we will
11+
move this into a Cython module for speed and to leverage native FlatHashMap
12+
inserts without Python overhead.
13+
"""
14+
15+
from typing import List
16+
from typing import Optional
17+
from typing import Tuple
18+
19+
import numpy as np
20+
import pyarrow as pa
21+
22+
from opteryx.compiled.aggregations.count_distinct import count_distinct
23+
from opteryx.draken.morsels.morsel import Morsel
24+
from opteryx.third_party.abseil.containers import FlatHashMap
25+
26+
# Supported aggregator functions (in the same names used in AGGREGATORS mapping)
27+
SUPPORTED_FUNCS = {"sum", "min", "max", "count", "count_distinct", "hash_one", "mean"}
28+
29+
30+
def _take_array_as_numpy(arr: pa.Array, indices: List[int]):
31+
# Fast path: use to_numpy if available, otherwise fall back to to_pylist
32+
try:
33+
return arr.to_numpy(False)
34+
except Exception:
35+
return np.asarray(arr.to_pylist(), dtype=object)
36+
37+
38+
def _compute_agg_value(vec, func: str, indices) -> object:
39+
"""
40+
vec: draken Vector (has methods `take()` and `to_arrow()` or `to_arrow()` directly)
41+
func: one of SUPPORTED_FUNCS
42+
indices: list-like indices
43+
"""
44+
# Handle COUNT as special
45+
if func == "count":
46+
return len(indices)
47+
if func == "count_distinct":
48+
# Build arrow array for selected indices and call compiled count_distinct
49+
arr = vec.to_arrow() if hasattr(vec, "to_arrow") else vec
50+
taken = arr.take(pa.array(indices, type=pa.int32()))
51+
seen = count_distinct(taken, None)
52+
# count_distinct returns a FlatHashSet
53+
return seen.items()
54+
# Convert vec.take to a vector for other functions
55+
try:
56+
taken_vec = vec.take(indices)
57+
except Exception:
58+
# fallback to arrow array take
59+
arr = vec.to_arrow() if hasattr(vec, "to_arrow") else vec
60+
taken_pa = arr.take(pa.array(indices, type=pa.int32()))
61+
# Convert to numpy and use numpy to compute
62+
npvals = _take_array_as_numpy(taken_pa)
63+
if func == "sum":
64+
return np.nansum(npvals)
65+
elif func == "min":
66+
return np.nanmin(npvals)
67+
elif func == "max":
68+
return np.nanmax(npvals)
69+
elif func == "mean":
70+
return float(np.nanmean(npvals))
71+
else:
72+
raise ValueError(f"Unsupported aggregator function: {func}")
73+
74+
# If taken_vec exposes methods for sum/min/max
75+
if func == "sum":
76+
try:
77+
return taken_vec.sum()
78+
except Exception:
79+
arr = taken_vec.to_arrow()
80+
return pa.compute.sum(arr).as_py()
81+
elif func == "min":
82+
try:
83+
return taken_vec.min()
84+
except Exception:
85+
arr = taken_vec.to_arrow()
86+
return pa.compute.min(arr).as_py()
87+
elif func == "max":
88+
try:
89+
return taken_vec.max()
90+
except Exception:
91+
arr = taken_vec.to_arrow()
92+
return pa.compute.max(arr).as_py()
93+
elif func == "mean":
94+
try:
95+
return taken_vec.sum() / len(indices) if len(indices) > 0 else None
96+
except Exception:
97+
arr = taken_vec.to_arrow()
98+
return pa.compute.mean(arr).as_py()
99+
elif func == "hash_one":
100+
try:
101+
return taken_vec.sum()
102+
except Exception:
103+
arr = taken_vec.to_arrow()
104+
# Best-effort fallback: sum of values
105+
return pa.compute.sum(arr).as_py()
106+
else:
107+
raise ValueError(f"Unsupported aggregator function: {func}")
108+
109+
110+
def group_by_morsel(
111+
morsel: Morsel,
112+
group_by_columns: List[str],
113+
aggregate_functions: List[Tuple[str, str, Optional[object]]],
114+
internal_names: List[str],
115+
column_names: List[str],
116+
):
117+
"""
118+
Prototype function to group a Draken Morsel using Draken hash and compute simple aggregates.
119+
120+
Parameters:
121+
morsel: Draken Morsel
122+
group_by_columns: list[str]
123+
aggregate_functions: list[(field_name, function, count_options)] - matches ``self.aggregate_functions`` from the Python
124+
plan nodes
125+
internal_names: list[str] the names of the aggregated columns (internal), same length as aggregate_functions
126+
column_names: list[str] external names to rename aggregated columns to
127+
128+
Returns:
129+
pyarrow.Table: grouped result with columns in order internal_names + group_by_columns
130+
"""
131+
# Fast-path: empty table
132+
if morsel is None or morsel.num_rows == 0:
133+
# Return an empty table with the expected schema
134+
return pa.Table.from_pydict({name: [] for name in internal_names + group_by_columns})
135+
136+
# Convert group_by columns to encoded bytes as Draken expects (Morsel.column accepts bytes too)
137+
gb_cols = [c if isinstance(c, bytes) else c.encode("utf-8") for c in group_by_columns]
138+
139+
# Use Draken's hash per-row for grouping
140+
if len(gb_cols) == 0:
141+
# entire table is a single group
142+
# compute per-aggregator result over all rows
143+
indices = list(range(morsel.num_rows))
144+
row_groups = {0: indices}
145+
else:
146+
# row_hashes is a memoryview of uint64
147+
row_hashes = morsel.hash(columns=gb_cols)
148+
149+
# Map 64-bit hash -> list of indices
150+
fmap = FlatHashMap()
151+
# iterate rows
152+
num_rows = morsel.num_rows
153+
for i in range(num_rows):
154+
h = int(row_hashes[i])
155+
fmap.insert(h, i)
156+
157+
# Move map to Python dict of key->list(indices)
158+
# `fmap.get` returns a vector[int64]; we can treat it as list
159+
row_groups = {}
160+
# Iterate keys using fmap.size and collecting get(key) (we can't iterate keys; but we can get all internal map.get for known keys; however FlatHashMap doesn't expose keys.
161+
# Workaround: we can rebuild the Python dict by iterating rows and using dict
162+
row_groups = {}
163+
for i in range(num_rows):
164+
h = int(row_hashes[i])
165+
if h not in row_groups:
166+
row_groups[h] = [i]
167+
else:
168+
row_groups[h].append(i)
169+
170+
# Now compute group-level aggregations
171+
result_internal = {name: [] for name in internal_names}
172+
result_group = {name: [] for name in group_by_columns}
173+
174+
# We'll need the draken vector for aggregator columns
175+
for key, indices in row_groups.items():
176+
# For group by values (take first row value of each gb column)
177+
for gb_col in gb_cols:
178+
vec = morsel.column(gb_col)
179+
val = vec[indices[0]]
180+
# store under original str column name
181+
result_group[gb_col.decode("utf-8")].append(val)
182+
183+
# Compute each aggregator
184+
for (agg_idx, (field_name, func_name, _)), internal_name in zip(
185+
enumerate(aggregate_functions), internal_names
186+
):
187+
# field_name is the actual field to aggregate
188+
vec = morsel.column(
189+
field_name.encode("utf-8") if isinstance(field_name, str) else field_name
190+
)
191+
if func_name in ("hash_one", "hash_list"):
192+
# use sum as proxy for hash_one
193+
val = _compute_agg_value(vec, "sum", indices)
194+
else:
195+
val = _compute_agg_value(vec, func_name, indices)
196+
# For AVG, compute as sum/count
197+
if func_name == "mean":
198+
# already computed as mean in compute_agg_value
199+
pass
200+
result_internal[internal_name].append(val)
201+
202+
# Build pyarrow Table
203+
# Order: internal_names + group_by_columns
204+
columns = {}
205+
for name in internal_names:
206+
columns[name] = result_internal[name]
207+
for gb in group_by_columns:
208+
columns[gb] = result_group[gb]
209+
210+
table = pa.Table.from_pydict(columns)
211+
212+
# Rename internal columns to user column_names
213+
if len(internal_names) == len(column_names) - len(group_by_columns):
214+
# column_names were alias names; original code renames columns after selecting; we'll mirror expected behavior
215+
# column_names includes alias names + group_by
216+
# So positionally name selectors: first portion are alias names
217+
alias_map = {}
218+
alias_columns = column_names[: len(internal_names)]
219+
for internal, alias in zip(internal_names, alias_columns):
220+
# rename by building a new table
221+
table = table.rename_columns(
222+
[alias if n == internal else n for n in table.column_names]
223+
)
224+
225+
return table

0 commit comments

Comments
 (0)