Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/source/future.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
What's New?
###########

Version 1.4 (June 9, 2026)
~~~~~~~~~~~~~~~~~~~~~~~~~~
- faster large-array preprocessing using a parallel scan while preserving the previous row-major compact coordinate order
- lower memory pressure during preprocessing by avoiding full ``m * n`` coordinate scratch arrays on large inputs
- faster grouped height reductions with a threaded ``groupby_max`` path for large NaN coordinate arrays
- faster row/column ordering in large bounded-height cases using counting sort instead of general-purpose ``argsort``
- benchmarked on a ``100_000 x 1_000`` matrix with ``2.5%`` MAR and 5 warmed trials: median solve time reduced from about ``134 ms`` to about ``57 ms``
- added regression coverage to ensure the parallel preprocessing path matches the serial preprocessing output exactly


Version 1.3 (July 31, 2024)
~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
200 changes: 192 additions & 8 deletions optimask/_optimask.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import numpy as np
import pandas as pd
from numba import bool_, njit, prange, uint32
from numba import bool_, get_num_threads, njit, prange, uint32
from numba.types import UniTuple

from ._misc import (
Expand Down Expand Up @@ -51,9 +51,55 @@ def groupby_max(a, b, n):
ret = np.zeros(n, dtype=np.uint32)
for k in range(size_a):
ak = a[k]
ret[ak] = max(ret[ak], b[k] + 1)
value = b[k] + 1
if ret[ak] < value:
ret[ak] = value
return ret

@staticmethod
@njit(uint32[:](uint32[:], uint32[:], uint32, uint32), parallel=True, boundscheck=False, cache=True)
def groupby_max_parallel(a, b, n, n_threads):
"""
Threaded equivalent of ``groupby_max``.

Each thread writes to a private scratch row, then the rows are reduced
into the final result. This avoids races while keeping the hot loop
parallel for large NaN coordinate arrays.
"""
size_a = len(a)
chunk_size = (size_a + n_threads - 1) // n_threads
scratch = np.zeros((n_threads, n), dtype=np.uint32)

for thread_id in prange(n_threads):
start = thread_id * chunk_size
end = start + chunk_size
if end > size_a:
end = size_a

local = scratch[thread_id]
for k in range(start, end):
ak = a[k]
value = b[k] + 1
if local[ak] < value:
local[ak] = value

ret = np.zeros(n, dtype=np.uint32)
for i in prange(n):
best = np.uint32(0)
for thread_id in range(n_threads):
value = scratch[thread_id, i]
if best < value:
best = value
ret[i] = best
return ret

@classmethod
def _groupby_max(cls, a, b, n):
n_threads = get_num_threads()
if a.size >= 1_000_000 and int(n) * n_threads * np.dtype(np.uint32).itemsize <= 64 * 1024**2:
return cls.groupby_max_parallel(a, b, n, n_threads)
return cls.groupby_max(a, b, n)

@staticmethod
@njit(bool_(uint32[:]), boundscheck=False, cache=True)
def is_decreasing(h):
Expand All @@ -66,6 +112,34 @@ def is_decreasing(h):
return False
return True

@staticmethod
@njit(uint32[:](uint32[:], uint32), boundscheck=False, cache=True)
def counting_argsort_decreasing(h, n_bins):
counts = np.zeros(n_bins, dtype=np.uint32)
for i in range(h.size):
counts[h[i]] += 1

offsets = np.empty(n_bins, dtype=np.uint32)
position = np.uint32(0)
for k in range(n_bins):
value = n_bins - k - 1
offsets[value] = position
position += counts[value]

result = np.empty(h.size, dtype=np.uint32)
for i in range(h.size):
value = h[i]
position = offsets[value]
result[position] = i
offsets[value] = position + 1
return result

@classmethod
def argsort_decreasing(cls, h, n_bins, kind):
if h.size >= 4096 and n_bins <= h.size:
return cls.counting_argsort_decreasing(h, n_bins)
return (-h).argsort(kind=kind).astype(np.uint32)

@staticmethod
@njit(uint32[:](uint32[:], uint32[:]), parallel=True, boundscheck=False, cache=True)
def numba_apply_permutation(p, x):
Expand Down Expand Up @@ -182,6 +256,116 @@ def _preprocess(x):
cols_with_nan = cols_with_nan[:n_cols_with_nan]
return iy, ix, rows_with_nan, cols_with_nan

@staticmethod
@njit(parallel=True, boundscheck=False, cache=True)
def _preprocess_parallel(x, n_threads):
"""
Parallel preprocessing for large arrays.

The column order intentionally matches ``_preprocess``: columns are
ordered by their first row-major occurrence in the input.
"""
m, n = x.shape
row_counts = np.zeros(m, dtype=np.int64)
first_rows_by_thread = np.empty((n_threads, n), dtype=np.int64)

for thread_id in prange(n_threads):
for j in range(n):
first_rows_by_thread[thread_id, j] = m

chunk_size = (m + n_threads - 1) // n_threads
for thread_id in prange(n_threads):
start = thread_id * chunk_size
end = start + chunk_size
if end > m:
end = m

first_rows = first_rows_by_thread[thread_id]
for i in range(start, end):
count = 0
for j in range(n):
if np.isnan(x[i, j]):
count += 1
if first_rows[j] == m:
first_rows[j] = i
row_counts[i] = count

first_rows = np.empty(n, dtype=np.int64)
for j in prange(n):
first_row = m
for thread_id in range(n_threads):
candidate = first_rows_by_thread[thread_id, j]
if candidate < first_row:
first_row = candidate
first_rows[j] = first_row

total = 0
n_rows_with_nan = 0
for i in range(m):
total += row_counts[i]
if row_counts[i] > 0:
n_rows_with_nan += 1

n_cols_with_nan = 0
for j in range(n):
if first_rows[j] < m:
n_cols_with_nan += 1

rows_with_nan = np.empty(n_rows_with_nan, dtype=np.uint32)
cols_with_nan = np.empty(n_cols_with_nan, dtype=np.uint32)
iy = np.empty(total, dtype=np.uint32)
ix = np.empty(total, dtype=np.uint32)

if total == 0:
return iy, ix, rows_with_nan, cols_with_nan

row_map = np.empty(m, dtype=np.uint32)
col_map = np.empty(n, dtype=np.uint32)
row_offsets = np.empty(m, dtype=np.int64)

position = 0
row_id = 0
for i in range(m):
row_offsets[i] = position
if row_counts[i] > 0:
rows_with_nan[row_id] = i
row_map[i] = row_id
row_id += 1
position += row_counts[i]

keys = np.empty(n, dtype=np.int64)
for j in range(n):
keys[j] = first_rows[j] * n + j
col_order = np.argsort(keys)

col_id = 0
for k in range(n):
j = col_order[k]
if first_rows[j] < m:
cols_with_nan[col_id] = j
col_map[j] = col_id
col_id += 1

for i in prange(m):
if row_counts[i] > 0:
position = row_offsets[i]
row_id = row_map[i]
for j in range(n):
if np.isnan(x[i, j]):
iy[position] = row_id
ix[position] = col_map[j]
position += 1

return iy, ix, rows_with_nan, cols_with_nan

@classmethod
def _preprocess_auto(cls, x):
n_threads = get_num_threads()
scratch_size = n_threads * x.shape[1] * np.dtype(np.int64).itemsize
if x.size >= 1_000_000 and n_threads > 1 and scratch_size <= 64 * 1024**2:
return cls._preprocess_parallel(x, n_threads)
return cls._preprocess(x)

def _trial(self, k, rng, m_nan, n_nan, iy, ix, m, n):
if k:
p_rows = rng.permutation(m_nan).astype(np.uint32)
Expand All @@ -194,23 +378,23 @@ def _trial(self, k, rng, m_nan, n_nan, iy, ix, m, n):
iy_trial = iy.copy()
ix_trial = ix.copy()

hy = self.groupby_max(iy_trial, ix_trial, m_nan)
hy = self._groupby_max(iy_trial, ix_trial, m_nan)
step = 0
is_pareto_ordered = False
while not is_pareto_ordered and step < self.max_steps:
kind = "stable" if step else "quicksort"
axis = step % 2
step += 1
if axis == 0:
p_step = (-hy).argsort(kind=kind).astype(np.uint32)
p_step = self.argsort_decreasing(hy, n_nan + 1, kind)
self.apply_permutation(p_step, iy_trial, inplace=True)
p_rows, hy = self.apply_p_step(p_step, p_rows, hy)
hx = self.groupby_max(ix_trial, iy_trial, n_nan)
hx = self._groupby_max(ix_trial, iy_trial, n_nan)
is_pareto_ordered = self.is_decreasing(hx)
else:
p_step = (-hx).argsort(kind=kind).astype(np.uint32)
p_step = self.argsort_decreasing(hx, m_nan + 1, kind)
self.apply_permutation(p_step, ix_trial, inplace=True)
hy = self.groupby_max(iy_trial, ix_trial, m_nan)
hy = self._groupby_max(iy_trial, ix_trial, m_nan)
p_cols, hx = self.apply_p_step(p_step, p_cols, hx)
is_pareto_ordered = self.is_decreasing(hy)

Expand Down Expand Up @@ -260,7 +444,7 @@ def _solve(self, x):
if n == 1:
return np.flatnonzero(np.isfinite(x.ravel())), np.arange(n)

iy, ix, rows_with_nan, cols_with_nan = self._preprocess(x)
iy, ix, rows_with_nan, cols_with_nan = self._preprocess_auto(x)
m_nan, n_nan = len(rows_with_nan), len(cols_with_nan)
if len(iy) == 0:
return np.arange(m), np.arange(n)
Expand Down
16 changes: 15 additions & 1 deletion optimask/utils/_plot.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import matplotlib.pyplot as plt
from importlib import import_module
from typing import Any

import numpy as np


def _load_pyplot() -> Any:
try:
return import_module("matplotlib.pyplot")
except ModuleNotFoundError as exc:
if exc.name == "matplotlib":
msg = "plot() requires matplotlib. Install it with `pip install optimask[plot]`."
raise ImportError(msg) from exc
raise


def plot(
data,
rows_to_keep=None,
Expand Down Expand Up @@ -36,6 +48,7 @@ def plot(

Raises:
ValueError: If the `data` input is not a 2D array.
ImportError: If matplotlib is not installed.

Notes:
- Rows and columns specified in `rows_to_keep` and `cols_to_keep` remain unchanged.
Expand All @@ -47,6 +60,7 @@ def plot(
>>> data = np.random.rand(10, 10)
>>> plot(data, rows_to_keep=[1, 2], cols_to_remove=[3, 4], title="Sample Plot", xticks=list('ABCDEFGHIJ'), yticks=range(10))
"""
plt = _load_pyplot()
cmap = plt.get_cmap("coolwarm")
cmap.set_bad("grey")
x = data.copy()
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "optimask"
version = "1.3.12"
version = "1.4"
description = "OptiMask: extracting the largest (non-contiguous) submatrix without NaN"
readme = "README.md"
authors = [
Expand All @@ -20,6 +20,11 @@ dependencies = [
"numba"
]

[project.optional-dependencies]
plot = [
"matplotlib"
]

[project.urls]
documentation = "https://optimask.readthedocs.io"

Expand Down
10 changes: 10 additions & 0 deletions tests/test_optimask.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,16 @@ def test_seed():
assert np.allclose(cols1, cols2)


def test_parallel_preprocess_matches_serial():
X = generate_random(m=1_000, n=1_000, ratio=0.025)

serial = OptiMask._preprocess(X)
parallel = OptiMask._preprocess_parallel(X, n_threads=4)

for serial_item, parallel_item in zip(serial, parallel):
assert np.array_equal(serial_item, parallel_item)


def test_speed(opti_mask_instance):
x = generate_random(m=100_000, n=1_000, ratio=0.02)
print("\nVertical arrays")
Expand Down
Loading