Skip to content

Commit ac8d83c

Browse files
authored
Merge pull request #224 from static-frame/223/group-reduce
`group_reduce()`
2 parents 3b41a75 + 44a2283 commit ac8d83c

7 files changed

Lines changed: 363 additions & 0 deletions

File tree

README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ ArrayKit requires the following:
3535
What is New in ArrayKit
3636
-------------------------
3737

38+
1.11.0
39+
............
40+
41+
Added ``group_reduce()``.
42+
43+
3844
1.10.0
3945
............
4046

src/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ._arraykit import write_array_to_file as write_array_to_file
2828
from ._arraykit import factorize as factorize
2929
from ._arraykit import group_ordering as group_ordering
30+
from ._arraykit import group_reduce as group_reduce
3031
from ._arraykit import fill_directional as fill_directional
3132
from ._arraykit import count_iteration as count_iteration
3233
from ._arraykit import first_true_1d as first_true_1d

src/__init__.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,9 @@ def factorize(
233233
def group_ordering(
234234
codes: np.ndarray, *, size: tp.Optional[int] = ...
235235
) -> tp.Tuple[np.ndarray, np.ndarray]: ...
236+
def group_reduce(
237+
codes: np.ndarray, size: int, values: np.ndarray, op: str
238+
) -> np.ndarray: ...
236239
def fill_directional(
237240
array: np.ndarray,
238241
target: np.ndarray,

src/_arraykit.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ static PyMethodDef arraykit_methods[] = {
7878
(PyCFunction)group_ordering,
7979
METH_VARARGS | METH_KEYWORDS,
8080
NULL},
81+
{"group_reduce",
82+
(PyCFunction)group_reduce,
83+
METH_VARARGS | METH_KEYWORDS,
84+
NULL},
8185
{"fill_directional",
8286
(PyCFunction)fill_directional,
8387
METH_VARARGS | METH_KEYWORDS,

src/methods.c

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
# include "numpy/arrayscalars.h"
99
# include "numpy/halffloat.h"
1010
# include <string.h>
11+
# include <math.h>
1112

1213
# ifdef _WIN32
1314
# include <io.h>
@@ -1128,6 +1129,202 @@ group_ordering(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs)
11281129
return NULL;
11291130
}
11301131

1132+
typedef enum {
1133+
GR_SUM,
1134+
GR_PROD,
1135+
GR_MIN,
1136+
GR_MAX,
1137+
GR_COUNT,
1138+
} AK_GroupReduceOp;
1139+
1140+
static int
1141+
AK_group_reduce_op_from_str(const char *op, AK_GroupReduceOp *out) {
1142+
if (strcmp(op, "sum") == 0) { *out = GR_SUM; return 0; }
1143+
if (strcmp(op, "prod") == 0) { *out = GR_PROD; return 0; }
1144+
if (strcmp(op, "min") == 0) { *out = GR_MIN; return 0; }
1145+
if (strcmp(op, "max") == 0) { *out = GR_MAX; return 0; }
1146+
if (strcmp(op, "count") == 0) { *out = GR_COUNT; return 0; }
1147+
PyErr_Format(PyExc_ValueError,
1148+
"unknown op '%s'; expected one of sum, prod, min, max, count", op);
1149+
return -1;
1150+
}
1151+
1152+
// Accumulate `n` float64 into `out[size]` per group. NaN propagates for min/max
1153+
// (matching np.min/np.max, not the nan-skipping variants).
1154+
static void
1155+
AK_group_reduce_f64(
1156+
const npy_float64 *v,
1157+
const npy_intp *codes,
1158+
npy_intp n,
1159+
npy_float64 *out,
1160+
npy_intp size,
1161+
AK_GroupReduceOp op) {
1162+
npy_float64 init;
1163+
switch (op) {
1164+
case GR_PROD: init = 1.0; break;
1165+
case GR_MIN: init = NPY_INFINITY; break;
1166+
case GR_MAX: init = -NPY_INFINITY; break;
1167+
default: init = 0.0; break; // GR_SUM
1168+
}
1169+
for (npy_intp g = 0; g < size; g++) {
1170+
out[g] = init;
1171+
}
1172+
for (npy_intp i = 0; i < n; i++) {
1173+
npy_intp g = codes[i];
1174+
npy_float64 x = v[i];
1175+
switch (op) {
1176+
case GR_SUM: out[g] += x; break;
1177+
case GR_PROD: out[g] *= x; break;
1178+
case GR_MIN: if (isnan(x) || x < out[g]) out[g] = x; break;
1179+
case GR_MAX: if (isnan(x) || x > out[g]) out[g] = x; break;
1180+
default: break;
1181+
}
1182+
}
1183+
}
1184+
1185+
// Accumulate `n` int64 into `out[size]` per group.
1186+
static void
1187+
AK_group_reduce_i64(
1188+
const npy_int64 *v,
1189+
const npy_intp *codes,
1190+
npy_intp n,
1191+
npy_int64 *out,
1192+
npy_intp size,
1193+
AK_GroupReduceOp op) {
1194+
npy_int64 init;
1195+
switch (op) {
1196+
case GR_PROD: init = 1; break;
1197+
case GR_MIN: init = NPY_MAX_INT64; break;
1198+
case GR_MAX: init = NPY_MIN_INT64; break;
1199+
default: init = 0; break; // GR_SUM
1200+
}
1201+
for (npy_intp g = 0; g < size; g++) {
1202+
out[g] = init;
1203+
}
1204+
for (npy_intp i = 0; i < n; i++) {
1205+
npy_intp g = codes[i];
1206+
npy_int64 x = v[i];
1207+
switch (op) {
1208+
case GR_SUM: out[g] += x; break;
1209+
case GR_PROD: out[g] *= x; break;
1210+
case GR_MIN: if (x < out[g]) out[g] = x; break;
1211+
case GR_MAX: if (x > out[g]) out[g] = x; break;
1212+
default: break;
1213+
}
1214+
}
1215+
}
1216+
1217+
static char *group_reduce_kwarg_names[] = {
1218+
"codes",
1219+
"size",
1220+
"values",
1221+
"op",
1222+
NULL
1223+
};
1224+
1225+
// Grouped reduction. Given dense group `codes` in [0, size), a 1D
1226+
// `values` array, and an `op` ('sum'/'prod'/'min'/'max'/'count'), return a length-
1227+
// `size` array of per-group results in code order. Accumulates directly by code in
1228+
// an O(n) pass after validating codes (no sort, no reorder). 'count' returns int64 group sizes and ignores
1229+
// the values dtype; other ops return the values dtype (float64 or int64). This is
1230+
// the vectorized replacement for a per-group Python reduction loop.
1231+
PyObject *
1232+
group_reduce(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs)
1233+
{
1234+
PyArrayObject *codes = NULL;
1235+
Py_ssize_t size = 0;
1236+
PyArrayObject *values = NULL;
1237+
const char *op_name = NULL;
1238+
1239+
if (!PyArg_ParseTupleAndKeywords(args, kwargs,
1240+
"O!nO!s:group_reduce",
1241+
group_reduce_kwarg_names,
1242+
&PyArray_Type, &codes,
1243+
&size,
1244+
&PyArray_Type, &values,
1245+
&op_name
1246+
)) {
1247+
return NULL;
1248+
}
1249+
AK_GroupReduceOp op;
1250+
if (AK_group_reduce_op_from_str(op_name, &op)) {
1251+
return NULL;
1252+
}
1253+
if (size < 0) {
1254+
PyErr_SetString(PyExc_ValueError, "size must be non-negative");
1255+
return NULL;
1256+
}
1257+
if (PyArray_NDIM(codes) != 1 || PyArray_NDIM(values) != 1) {
1258+
PyErr_SetString(PyExc_ValueError, "Arrays must be 1-dimensional");
1259+
return NULL;
1260+
}
1261+
if (PyArray_TYPE(codes) != NPY_INTP) {
1262+
PyErr_SetString(PyExc_ValueError, "codes must be of type intp");
1263+
return NULL;
1264+
}
1265+
if (!PyArray_IS_C_CONTIGUOUS(codes) || !PyArray_IS_C_CONTIGUOUS(values)) {
1266+
PyErr_SetString(PyExc_ValueError, "Arrays must be contiguous");
1267+
return NULL;
1268+
}
1269+
npy_intp n = PyArray_SIZE(codes);
1270+
if (PyArray_SIZE(values) != n) {
1271+
PyErr_SetString(PyExc_ValueError,
1272+
"codes and values must be the same length");
1273+
return NULL;
1274+
}
1275+
const npy_intp *codes_buffer = (npy_intp*)PyArray_DATA(codes);
1276+
// validate codes are in range before any indexed writes into the output
1277+
for (npy_intp i = 0; i < n; i++) {
1278+
npy_intp c = codes_buffer[i];
1279+
if (c < 0 || c >= size) {
1280+
PyErr_Format(PyExc_ValueError,
1281+
"code %zd out of range [0, %zd)",
1282+
(Py_ssize_t)c, (Py_ssize_t)size);
1283+
return NULL;
1284+
}
1285+
}
1286+
1287+
npy_intp dims[1] = {size};
1288+
int vtype = PyArray_TYPE(values);
1289+
1290+
if (op == GR_COUNT) {
1291+
PyObject *out_arr = PyArray_ZEROS(1, dims, NPY_INT64, 0);
1292+
if (!out_arr) {
1293+
return NULL;
1294+
}
1295+
npy_int64 *out = (npy_int64*)PyArray_DATA((PyArrayObject*)out_arr);
1296+
for (npy_intp i = 0; i < n; i++) {
1297+
out[codes_buffer[i]]++;
1298+
}
1299+
PyArray_CLEARFLAGS((PyArrayObject*)out_arr, NPY_ARRAY_WRITEABLE);
1300+
return out_arr;
1301+
}
1302+
1303+
if (vtype != NPY_DOUBLE && vtype != NPY_INT64) {
1304+
PyErr_SetString(PyExc_ValueError,
1305+
"values must be of type float64 or int64");
1306+
return NULL;
1307+
}
1308+
PyObject *out_arr = PyArray_EMPTY(1, dims, vtype, 0);
1309+
if (!out_arr) {
1310+
return NULL;
1311+
}
1312+
if (vtype == NPY_DOUBLE) {
1313+
AK_group_reduce_f64(
1314+
(npy_float64*)PyArray_DATA(values),
1315+
codes_buffer, n,
1316+
(npy_float64*)PyArray_DATA((PyArrayObject*)out_arr), size, op);
1317+
}
1318+
else {
1319+
AK_group_reduce_i64(
1320+
(npy_int64*)PyArray_DATA(values),
1321+
codes_buffer, n,
1322+
(npy_int64*)PyArray_DATA((PyArrayObject*)out_arr), size, op);
1323+
}
1324+
PyArray_CLEARFLAGS((PyArrayObject*)out_arr, NPY_ARRAY_WRITEABLE);
1325+
return out_arr;
1326+
}
1327+
11311328
// Fill one strided lane in place: walk positions in the fill direction, carrying
11321329
// the most recent non-target value into each target position (subject to `limit`
11331330
// consecutive fills per run). `elem_base`/`elem_stride` address elements in bytes;

src/methods.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ first_true_2d(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs);
7272
PyObject *
7373
group_ordering(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs);
7474

75+
PyObject *
76+
group_reduce(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs);
77+
7578
PyObject *
7679
fill_directional(PyObject *Py_UNUSED(m), PyObject *args, PyObject *kwargs);
7780

0 commit comments

Comments
 (0)