Approach summary:- Replace Python-level per-group loops with either vectorized Pandas/Numpy, C-accelerated libs, or JIT/C-extensions. Best pragmatic step: extract minimal numeric arrays from pandas, implement group-wise logic in numba (no pandas inside), then map results back. This removes Python/GIL overhead per group and is low-effort.Example: suppose we compute per-group a custom metric: weighted trimmed mean (drop top/bottom 5% then weighted mean). Original slow approach used groupby.apply(custom_fn). Below is a numba approach with minimal pandas interaction.python
import numpy as np
import pandas as pd
from numba import njit
# sample data
df = pd.DataFrame({
"grp": np.repeat(np.arange(1000), 100),
"value": np.random.randn(1000*100),
"w": np.random.rand(1000*100)
})
# prepare arrays: group ids (sorted) and contiguous slices
order = np.argsort(df["grp"].values)
grp_sorted = df["grp"].values[order]
val_sorted = df["value"].values[order]
w_sorted = df["w"].values[order]
# compute group boundaries
@njit
def group_boundaries(grp):
n = grp.size
boundaries = []
if n == 0:
return np.empty((0,2), dtype=np.int64)
start = 0
for i in range(1, n):
if grp[i] != grp[i-1]:
boundaries.append((start, i)) # [start, end)
start = i
boundaries.append((start, n))
out = np.empty((len(boundaries), 2), dtype=np.int64)
for i in range(len(boundaries)):
out[i, 0] = boundaries[i][0]
out[i, 1] = boundaries[i][1]
return out
@njit
def weighted_trimmed_mean(vals, weights, start, end, trim_frac=0.05):
# copy slice into local arrays
m = end - start
if m == 0:
return 0.0
vs = np.empty(m)
ws = np.empty(m)
for i in range(m):
vs[i] = vals[start + i]
ws[i] = weights[start + i]
# sort by value (simple argsort)
idx = np.argsort(vs)
cum_w = 0.0
total_w = 0.0
for i in range(m):
total_w += ws[i]
# trim by cumulative weight at both ends
low_cut = trim_frac * total_w
high_cut = (1 - trim_frac) * total_w
s = 0.0
weighted_sum = 0.0
kept_w = 0.0
for j in range(m):
i = idx[j]
s += ws[i]
if s <= low_cut:
continue
if s > high_cut:
break
weighted_sum += vs[i] * ws[i]
kept_w += ws[i]
if kept_w == 0:
return 0.0
return weighted_sum / kept_w
@njit
def compute_per_group(vals, weights, boundaries):
G = boundaries.shape[0]
out = np.empty(G)
for gi in range(G):
s = boundaries[gi, 0]
e = boundaries[gi, 1]
out[gi] = weighted_trimmed_mean(vals, weights, s, e, 0.05)
return out
# run
bounds = group_boundaries(grp_sorted)
res = compute_per_group(val_sorted, w_sorted, bounds)
# map back to group ids
group_ids = np.empty(bounds.shape[0], dtype=grp_sorted.dtype)
for i in range(bounds.shape
Approach summary:
- Replace Python-level per-group loops with vectorized NumPy/numba to remove Python/GIL overhead. Extract minimal numeric arrays from pandas, compute contiguous group slices, run JIT-compiled functions operating on raw arrays, then map results back to a DataFrame. This is low-effort and gives large speedups vs groupby.apply.
Example: compute a weighted trimmed mean per group (drop bottom/top 5% by cumulative weight, then weighted mean). We sort once by group to get contiguous slices, then run numba on arrays with no pandas inside.
pythonimport numpy as npimport pandas as pdfrom numba import njit# sample datan_groups = 1000per_group = 100df = pd.DataFrame({ "grp": np.repeat(np.arange(n_groups), per_group), "value": np.random.randn(n_groups * per_group), "w": np.random.rand(n_groups * per_group)})# prepare arrays (sort by group to get contiguous slices)order = np.argsort(df["grp"].values)grp_sorted = df["grp"].values[order]val_sorted = df["value"].values[order]w_sorted = df["w"].values[order]@njitdef group_boundaries(grp): n = grp.size if n == 0: return np.empty((0,2), dtype=np.int64) # count groups cnt = 1 for i in range(1, n): if grp[i] != grp[i-1]: cnt += 1 out = np.empty((cnt, 2), dtype=np.int64) start = 0 gi = 0 for i in range(1, n): if grp[i] != grp[i-1]: out[gi, 0] = start out[gi, 1] = i gi += 1 start = i out[gi, 0] = start out[gi, 1] = n return out@njitdef weighted_trimmed_mean(vals, weights, start, end, trim_frac): m = end - start if m <= 0: return 0.0 # copy slice vs = np.empty(m) ws = np.empty(m) for i in range(m): vs[i] = vals[start + i] ws[i] = weights[start + i] # argsort by value idx = np.argsort(vs) total_w = 0.0 for i in range(m): total_w += ws[i] low_cut = trim_frac * total_w high_cut = (1.0 - trim_frac) * total_w s = 0.0 weighted_sum = 0.0 kept_w = 0.0 for j in range(m): i = idx[j] s += ws[i] if s <= low_cut: continue if s > high_cut: break weighted_sum += vs[i] * ws[i] kept_w += ws[i] if kept_w == 0.0: return 0.0 return weighted_sum / kept_w@njitdef compute_per_group(vals, weights, boundaries, trim_frac): G = boundaries.shape[0] out = np.empty(G) for gi in range(G): s = boundaries[gi, 0] e = boundaries[gi, 1] out[gi] = weighted_trimmed_mean(vals, weights, s, e, trim_frac) return out# run numba pipelinebounds = group_boundaries(grp_sorted)res = compute_per_group(val_sorted, w_sorted, bounds, 0.05)# map back to group ids and produce result DataFramegroup_ids = np.empty(bounds.shape[0], dtype=grp_sorted.dtype)for i in range(bounds.shape[0]): group_ids[i] = grp_sorted[bounds[i, 0]]result_df = pd.DataFrame({"grp": group_ids, "wtrim_mean": res})Why this helps:
- Numba operates on raw NumPy arrays in native code — eliminates per-group Python callbacks.
- Sorting once and computing contiguous boundaries minimizes overhead.
- Minimal pandas interaction: only array extraction and final mapping.
Complexity and trade-offs:
- Time: O(N log N) due to initial sort; per-group work depends on group sizes (sorting within group is O(k log k)). If groups are small, overall is fast.
- Memory: uses extra arrays (slices, boundaries) but modest compared to full DataFrame.
- Alternatives: express fully vectorized using pandas/NumPy (if possible), use Cython for more control, or leverage C-accelerated libraries (numexpr, dask, polars) or groupby-aggregation in C (polars or SQL engines).
Edge cases & tips:
- If groups are already contiguous, skip global sort.
- For very large datasets, chunk processing or out-of-core frameworks (Dask, Polars) may be needed.
- Validate numeric stability for zero weights and empty groups.
- Benchmark vs original apply to ensure speedup; warm up numba compile time before measuring.