From cb1dd7de5c9f9a4cc3537413fd57c8e591c67b37 Mon Sep 17 00:00:00 2001
From: rossarmstrong <52817125+rossarmstrong@users.noreply.github.com>
Date: Mon, 15 Dec 2025 09:40:15 +1100
Subject: [PATCH 1/7] update version for new developmet branch
---
docs/source/conf.py | 2 +-
meson.build | 2 +-
pyproject.toml | 2 +-
werpy/__init__.py | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/source/conf.py b/docs/source/conf.py
index f95a820..cfc64ba 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -19,7 +19,7 @@
project = "werpy"
copyright = f'{datetime.now().year} Analytics in Motion'
author = "Ross Armstrong"
-release = "3.1.1"
+release = "3.2.0"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
diff --git a/meson.build b/meson.build
index f9033f3..4cd46a1 100644
--- a/meson.build
+++ b/meson.build
@@ -1,7 +1,7 @@
project(
'werpy',
'c', 'cython',
- version : '3.1.1',
+ version : '3.2.0',
license: 'BSD-3',
meson_version: '>= 1.1.0',
default_options : [
diff --git a/pyproject.toml b/pyproject.toml
index 2d558dc..ba4bc7f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,7 +9,7 @@ requires = [
[project]
name = 'werpy'
-version = '3.1.1'
+version = '3.2.0'
description = 'A powerful yet lightweight Python package to calculate and analyze the Word Error Rate (WER).'
readme = 'README.md'
requires-python = '>=3.10'
diff --git a/werpy/__init__.py b/werpy/__init__.py
index 09234a7..5778bc0 100644
--- a/werpy/__init__.py
+++ b/werpy/__init__.py
@@ -5,7 +5,7 @@
The werpy package provides tools for calculating word error rates (WERs) and related metrics on text data.
"""
-__version__ = "3.1.1"
+__version__ = "3.2.0"
from .errorhandler import error_handler
from .normalize import normalize
From 69734327fbb3f71c1558d6eb5666ec76e2dbd6be Mon Sep 17 00:00:00 2001
From: rossarmstrong <52817125+rossarmstrong@users.noreply.github.com>
Date: Mon, 15 Dec 2025 10:25:22 +1100
Subject: [PATCH 2/7] Add local testing scripts
---
.gitignore | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.gitignore b/.gitignore
index 8a48ec1..a643f2d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -185,3 +185,7 @@ cython_debug/
# Meson build directories
build/
builddir/
+
+# Personal development workspace (local testing only)
+development/
+!development/README.md
From 9c91210e73ad0512632e28b3efb88112a21c961b Mon Sep 17 00:00:00 2001
From: rossarmstrong <52817125+rossarmstrong@users.noreply.github.com>
Date: Mon, 15 Dec 2025 15:19:34 +1100
Subject: [PATCH 3/7] Refactor metrics computation to eliminate np.vectorize
overhead and fix double computation bug
---
werpy/errorhandler.py | 59 +++++++++++++++++++++++----------
werpy/metrics.pyx | 45 ++++++++++++++++++++++---
werpy/summary.py | 42 ++++++------------------
werpy/summaryp.py | 76 ++++++++++++-------------------------------
werpy/wer.py | 49 +++++++---------------------
werpy/werp.py | 68 +++++++++-----------------------------
werpy/werps.py | 76 ++++++++++++-------------------------------
werpy/wers.py | 44 ++++++-------------------
8 files changed, 171 insertions(+), 288 deletions(-)
diff --git a/werpy/errorhandler.py b/werpy/errorhandler.py
index 1f79aaa..e59ad3c 100644
--- a/werpy/errorhandler.py
+++ b/werpy/errorhandler.py
@@ -2,15 +2,19 @@
# SPDX-License-Identifier: BSD-3-Clause
"""
-Responsible for defining custom exceptions and handling errors across the package.
+Input validation and consistent exceptions for werpy public functions.
"""
-from .metrics import metrics
+import numpy as np
def error_handler(reference, hypothesis):
"""
- This function provides the overall wrapper to handle exceptions within this package.
+ Validate inputs and raise consistent exceptions.
+
+ This function does not compute metrics. Computation is handled by:
+ - metrics.metrics (router) for strings and batches
+ - metrics.calculations for a single pair
Parameters
----------
@@ -30,24 +34,43 @@ def error_handler(reference, hypothesis):
Returns
-------
- np.ndarray
- This function will return a ragged array containing the Word Error Rate, Levenshtein distance, the number of
- words in the reference sequence, insertions count, deletions count, substitutions count, a list of inserted
- words, a list of deleted words and a list of substituted words.
+ bool
+ True if validation passes.
"""
- try:
- word_error_rate_breakdown = metrics(reference, hypothesis)
- except ValueError as exc:
- raise ValueError(
- "The Reference and Hypothesis input parameters must have the same number of elements."
- ) from exc
- except AttributeError as exc:
+ valid_types = (str, list, np.ndarray)
+
+ if not isinstance(reference, valid_types) or not isinstance(hypothesis, valid_types):
raise AttributeError(
"All text should be in a string format. Please check your input does not include any "
"Numeric data types."
- ) from exc
- except ZeroDivisionError as exc:
+ )
+
+ ref_is_seq = isinstance(reference, (list, np.ndarray))
+ hyp_is_seq = isinstance(hypothesis, (list, np.ndarray))
+
+ if ref_is_seq != hyp_is_seq:
+ raise AttributeError(
+ "Reference and hypothesis must both be strings, or both be lists/arrays."
+ )
+
+ if ref_is_seq and hyp_is_seq:
+ if len(reference) != len(hypothesis):
+ raise ValueError(
+ "The Reference and Hypothesis input parameters must have the same number of elements."
+ )
+ return True
+
+ # At this point, both are strings (validated above)
+ ref_s = str(reference).strip()
+ hyp_s = str(hypothesis).strip()
+
+ if ref_s == "" and hyp_s == "":
raise ZeroDivisionError(
"Invalid input: reference must not be blank, and reference and hypothesis cannot both be empty."
- ) from exc
- return word_error_rate_breakdown
+ )
+ if ref_s == "":
+ raise ZeroDivisionError(
+ "Invalid input: reference must not be blank, and reference and hypothesis cannot both be empty."
+ )
+
+ return True
diff --git a/werpy/metrics.pyx b/werpy/metrics.pyx
index e54938f..20f76e7 100644
--- a/werpy/metrics.pyx
+++ b/werpy/metrics.pyx
@@ -91,7 +91,44 @@ cpdef cnp.ndarray calculations(object reference, object hypothesis):
[wer, ld, m, insertions, deletions, substitutions, inserted_words, deleted_words, substituted_words],
dtype=object)
-def metrics(reference, hypothesis):
- vectorize_calculations = np.vectorize(calculations)
- result = vectorize_calculations(reference, hypothesis)
- return result
+@cython.boundscheck(False)
+@cython.wraparound(False)
+cdef cnp.ndarray _metrics_batch(list references, list hypotheses):
+ """
+ Private batch processing function. Processes multiple reference-hypothesis
+ pairs at C speed, eliminating np.vectorize overhead.
+
+ Returns (n, 9) object array where each row contains:
+ [wer, ld, m, insertions, deletions, substitutions, inserted_words, deleted_words, substituted_words]
+ """
+ cdef Py_ssize_t n = len(references)
+ cdef Py_ssize_t idx, j
+
+ # Rows output, dtype=object because cols 6-8 are lists
+ cdef cnp.ndarray out = np.empty((n, 9), dtype=object)
+
+ cdef object r
+ for idx in range(n):
+ r = calculations(references[idx], hypotheses[idx])
+
+ # Safety: unwrap 0-D wrappers if they ever occur
+ if isinstance(r, np.ndarray) and r.ndim == 0:
+ r = r.item()
+
+ for j in range(9):
+ out[idx, j] = r[j]
+
+ return out
+
+
+cpdef object metrics(object reference, object hypothesis):
+ """
+ Unified fast metrics entry point (Option A, rows contract).
+
+ Returns:
+ - strings: a single row (len 9)
+ - sequences: an (n, 9) object ndarray, one row per pair
+ """
+ if isinstance(reference, (list, np.ndarray)) and isinstance(hypothesis, (list, np.ndarray)):
+ return _metrics_batch(list(reference), list(hypothesis))
+ return calculations(reference, hypothesis)
diff --git a/werpy/summary.py b/werpy/summary.py
index eae8d14..ebbe924 100644
--- a/werpy/summary.py
+++ b/werpy/summary.py
@@ -12,6 +12,7 @@
import numpy as np
import pandas as pd
from .errorhandler import error_handler
+from .metrics import metrics
def summary(reference, hypothesis) -> pd.DataFrame | None:
@@ -51,44 +52,21 @@ def summary(reference, hypothesis) -> pd.DataFrame | None:
word and the hypothesis word. For example: [(cited, sighted), (abnormally, normally)]
"""
try:
- word_error_rate_breakdown = error_handler(reference, hypothesis)
+ error_handler(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- b = word_error_rate_breakdown
-
- # Unwrap 0-D container
- if isinstance(b, np.ndarray) and b.ndim == 0:
- b = b.item()
-
- if isinstance(b, np.ndarray):
- if b.ndim == 2:
- # True 2-D numeric batch
- word_error_rate_breakdown = b.tolist()
-
- elif b.ndim == 1:
- # Could be either:
- # (a) single example row vector, or
- # (b) object array of per-example vectors
- first = b[0] if b.size else None
-
- if isinstance(first, (np.ndarray, list, tuple)):
- # Batch stored as 1-D object array of per-example vectors (ragged fields exist)
- word_error_rate_breakdown = []
- for r in b:
- rr = r.tolist() if isinstance(r, np.ndarray) else r
- word_error_rate_breakdown.append(rr)
- else:
- # Single example vector - wrap in list for DataFrame
- word_error_rate_breakdown = [b.tolist()]
-
- else:
- raise ValueError(f"Unexpected metrics output ndim: {b.ndim}")
+ result = metrics(reference, hypothesis)
+ # Batch rows (n, 9)
+ if isinstance(result, np.ndarray) and result.ndim == 2:
+ word_error_rate_breakdown = result.tolist()
else:
- # Non-numpy fallback (assume [wer, ld, m, ...])
- word_error_rate_breakdown = [b.tolist() if hasattr(b, 'tolist') else b]
+ # Single row - wrap in list for DataFrame
+ if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
+ result = result.item()
+ word_error_rate_breakdown = [result.tolist() if hasattr(result, 'tolist') else list(result)]
columns = [
"wer",
diff --git a/werpy/summaryp.py b/werpy/summaryp.py
index 9c087fd..03f03fb 100644
--- a/werpy/summaryp.py
+++ b/werpy/summaryp.py
@@ -13,6 +13,7 @@
import numpy as np
import pandas as pd
from .errorhandler import error_handler
+from .metrics import metrics
def summaryp(
@@ -65,68 +66,31 @@ def summaryp(
word and the hypothesis word. For example: [(cited, sighted), (abnormally, normally)]
"""
try:
- word_error_rate_breakdown = error_handler(reference, hypothesis)
+ error_handler(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- b = word_error_rate_breakdown
-
- # Unwrap 0-D container
- if isinstance(b, np.ndarray) and b.ndim == 0:
- b = b.item()
-
- if isinstance(b, np.ndarray):
- if b.ndim == 2:
- # True 2-D numeric batch
- word_error_rate_breakdown = b.tolist()
- t = b.T
- weighted_insertions = t[3] * insertions_weight
- weighted_deletions = t[4] * deletions_weight
- weighted_substitutions = t[5] * substitutions_weight
- m = t[2]
- weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
- werps_result = (weighted_errors / m).tolist()
-
- elif b.ndim == 1:
- # Could be either:
- # (a) single example row vector, or
- # (b) object array of per-example vectors
- first = b[0] if b.size else None
-
- if isinstance(first, (np.ndarray, list, tuple)):
- # Batch stored as 1-D object array of per-example vectors (ragged fields exist)
- word_error_rate_breakdown = []
- werps_result = []
- for r in b:
- rr = r.tolist() if isinstance(r, np.ndarray) else r
- word_error_rate_breakdown.append(rr)
- w_ins = float(rr[3]) * insertions_weight
- w_del = float(rr[4]) * deletions_weight
- w_sub = float(rr[5]) * substitutions_weight
- m_val = float(rr[2])
- weighted_wer = (w_ins + w_del + w_sub) / m_val if m_val else 0.0
- werps_result.append(weighted_wer)
- else:
- # Single example vector - wrap in list for DataFrame
- word_error_rate_breakdown = [b.tolist()]
- weighted_insertions = b[3] * insertions_weight
- weighted_deletions = b[4] * deletions_weight
- weighted_substitutions = b[5] * substitutions_weight
- m = b[2]
- weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
- werps_result = float(weighted_errors / m) if m else 0.0
-
- else:
- raise ValueError(f"Unexpected metrics output ndim: {b.ndim}")
+ result = metrics(reference, hypothesis)
+ # Batch rows (n, 9)
+ if isinstance(result, np.ndarray) and result.ndim == 2:
+ word_error_rate_breakdown = result.tolist()
+ weighted_insertions = result[:, 3] * insertions_weight
+ weighted_deletions = result[:, 4] * deletions_weight
+ weighted_substitutions = result[:, 5] * substitutions_weight
+ m = result[:, 2]
+ weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
+ werps_result = (weighted_errors / m).tolist()
else:
- # Non-numpy fallback (assume [wer, ld, m, ...])
- word_error_rate_breakdown = [b.tolist() if hasattr(b, 'tolist') else b]
- weighted_insertions = b[3] * insertions_weight
- weighted_deletions = b[4] * deletions_weight
- weighted_substitutions = b[5] * substitutions_weight
- m = b[2]
+ # Single row - wrap in list for DataFrame
+ if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
+ result = result.item()
+ word_error_rate_breakdown = [result.tolist() if hasattr(result, 'tolist') else list(result)]
+ weighted_insertions = result[3] * insertions_weight
+ weighted_deletions = result[4] * deletions_weight
+ weighted_substitutions = result[5] * substitutions_weight
+ m = result[2]
weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
werps_result = float(weighted_errors / m) if m else 0.0
diff --git a/werpy/wer.py b/werpy/wer.py
index 2d52bc8..3e0489b 100644
--- a/werpy/wer.py
+++ b/werpy/wer.py
@@ -12,6 +12,7 @@
import numpy as np
from .errorhandler import error_handler
+from .metrics import metrics
def wer(reference, hypothesis) -> float | np.float64 | None:
@@ -55,47 +56,21 @@ def wer(reference, hypothesis) -> float | np.float64 | None:
0.2
"""
try:
- word_error_rate_breakdown = error_handler(reference, hypothesis)
+ error_handler(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- b = word_error_rate_breakdown
+ result = metrics(reference, hypothesis)
- # Unwrap 0-D container
- if isinstance(b, np.ndarray) and b.ndim == 0:
- b = b.item()
+ # Batch rows (n, 9)
+ if isinstance(result, np.ndarray) and result.ndim == 2:
+ ld_total = float(np.sum(result[:, 1]))
+ m_total = float(np.sum(result[:, 2]))
+ return ld_total / m_total
- if isinstance(b, np.ndarray):
- if b.ndim == 2:
- # True 2-D numeric batch
- t = b.T
- wer_result = float(np.sum(t[1]) / np.sum(t[2]))
+ # Single row
+ if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
+ result = result.item()
- elif b.ndim == 1:
- # Could be either:
- # (a) single example row vector, or
- # (b) object array of per-example vectors
- first = b[0] if b.size else None
-
- if isinstance(first, (np.ndarray, list, tuple)):
- # Batch stored as 1-D object array of per-example vectors (ragged fields exist)
- total_ld = 0.0
- total_m = 0.0
- for r in b:
- rr = r.tolist() if isinstance(r, np.ndarray) else r
- total_ld += float(rr[1])
- total_m += float(rr[2])
- wer_result = float(total_ld / total_m) if total_m else 0.0
- else:
- # Single example vector
- wer_result = float(b[0])
-
- else:
- raise ValueError(f"Unexpected metrics output ndim: {b.ndim}")
-
- else:
- # Non-numpy fallback (assume [wer, ld, m, ...])
- wer_result = float(b[0])
-
- return wer_result
+ return float(result[1]) / float(result[2])
diff --git a/werpy/werp.py b/werpy/werp.py
index e5cee5b..2cdef59 100644
--- a/werpy/werp.py
+++ b/werpy/werp.py
@@ -11,6 +11,7 @@
import numpy as np
from .errorhandler import error_handler
+from .metrics import metrics
def werp(
@@ -73,64 +74,27 @@ def werp(
0.25
"""
try:
- word_error_rate_breakdown = error_handler(reference, hypothesis)
+ error_handler(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- b = word_error_rate_breakdown
-
- # Unwrap 0-D container
- if isinstance(b, np.ndarray) and b.ndim == 0:
- b = b.item()
-
- if isinstance(b, np.ndarray):
- if b.ndim == 2:
- # True 2-D numeric batch
- t = b.T
- weighted_insertions = np.sum(t[3]) * insertions_weight
- weighted_deletions = np.sum(t[4]) * deletions_weight
- weighted_substitutions = np.sum(t[5]) * substitutions_weight
- m = np.sum(t[2])
-
- elif b.ndim == 1:
- # Could be either:
- # (a) single example row vector, or
- # (b) object array of per-example vectors
- first = b[0] if b.size else None
-
- if isinstance(first, (np.ndarray, list, tuple)):
- # Batch stored as 1-D object array of per-example vectors (ragged fields exist)
- total_insertions = 0.0
- total_deletions = 0.0
- total_substitutions = 0.0
- total_m = 0.0
- for r in b:
- rr = r.tolist() if isinstance(r, np.ndarray) else r
- total_insertions += float(rr[3])
- total_deletions += float(rr[4])
- total_substitutions += float(rr[5])
- total_m += float(rr[2])
- weighted_insertions = total_insertions * insertions_weight
- weighted_deletions = total_deletions * deletions_weight
- weighted_substitutions = total_substitutions * substitutions_weight
- m = total_m
- else:
- # Single example vector
- weighted_insertions = b[3] * insertions_weight
- weighted_deletions = b[4] * deletions_weight
- weighted_substitutions = b[5] * substitutions_weight
- m = b[2]
-
- else:
- raise ValueError(f"Unexpected metrics output ndim: {b.ndim}")
+ result = metrics(reference, hypothesis)
+ # Batch rows (n, 9)
+ if isinstance(result, np.ndarray) and result.ndim == 2:
+ weighted_insertions = np.sum(result[:, 3]) * insertions_weight
+ weighted_deletions = np.sum(result[:, 4]) * deletions_weight
+ weighted_substitutions = np.sum(result[:, 5]) * substitutions_weight
+ m = np.sum(result[:, 2])
else:
- # Non-numpy fallback (assume [wer, ld, m, ...])
- weighted_insertions = b[3] * insertions_weight
- weighted_deletions = b[4] * deletions_weight
- weighted_substitutions = b[5] * substitutions_weight
- m = b[2]
+ # Single row
+ if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
+ result = result.item()
+ weighted_insertions = result[3] * insertions_weight
+ weighted_deletions = result[4] * deletions_weight
+ weighted_substitutions = result[5] * substitutions_weight
+ m = result[2]
weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
werp_result = float(weighted_errors / m) if m else 0.0
diff --git a/werpy/werps.py b/werpy/werps.py
index 4714af9..6e07584 100644
--- a/werpy/werps.py
+++ b/werpy/werps.py
@@ -11,6 +11,7 @@
import numpy as np
from .errorhandler import error_handler
+from .metrics import metrics
def werps(
@@ -67,64 +68,29 @@ def werps(
[0.21428571428571427, 0.2777777777777778]
"""
try:
- word_error_rate_breakdown = error_handler(reference, hypothesis)
+ error_handler(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- b = word_error_rate_breakdown
+ result = metrics(reference, hypothesis)
- # Unwrap 0-D container
- if isinstance(b, np.ndarray) and b.ndim == 0:
- b = b.item()
-
- if isinstance(b, np.ndarray):
- if b.ndim == 2:
- # True 2-D numeric batch
- t = b.T
- weighted_insertions = t[3] * insertions_weight
- weighted_deletions = t[4] * deletions_weight
- weighted_substitutions = t[5] * substitutions_weight
- m = t[2]
- weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
- werps_result = (weighted_errors / m).tolist()
-
- elif b.ndim == 1:
- # Could be either:
- # (a) single example row vector, or
- # (b) object array of per-example vectors
- first = b[0] if b.size else None
-
- if isinstance(first, (np.ndarray, list, tuple)):
- # Batch stored as 1-D object array of per-example vectors (ragged fields exist)
- werps_result = []
- for r in b:
- rr = r.tolist() if isinstance(r, np.ndarray) else r
- w_ins = float(rr[3]) * insertions_weight
- w_del = float(rr[4]) * deletions_weight
- w_sub = float(rr[5]) * substitutions_weight
- m_val = float(rr[2])
- weighted_wer = (w_ins + w_del + w_sub) / m_val if m_val else 0.0
- werps_result.append(weighted_wer)
- else:
- # Single example vector
- weighted_insertions = b[3] * insertions_weight
- weighted_deletions = b[4] * deletions_weight
- weighted_substitutions = b[5] * substitutions_weight
- m = b[2]
- weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
- werps_result = float(weighted_errors / m) if m else 0.0
-
- else:
- raise ValueError(f"Unexpected metrics output ndim: {b.ndim}")
-
- else:
- # Non-numpy fallback (assume [wer, ld, m, ...])
- weighted_insertions = b[3] * insertions_weight
- weighted_deletions = b[4] * deletions_weight
- weighted_substitutions = b[5] * substitutions_weight
- m = b[2]
+ # Batch rows (n, 9)
+ if isinstance(result, np.ndarray) and result.ndim == 2:
+ weighted_insertions = result[:, 3] * insertions_weight
+ weighted_deletions = result[:, 4] * deletions_weight
+ weighted_substitutions = result[:, 5] * substitutions_weight
+ m = result[:, 2]
weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
- werps_result = float(weighted_errors / m) if m else 0.0
-
- return werps_result
+ return (weighted_errors / m).tolist()
+
+ # Single row
+ if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
+ result = result.item()
+
+ weighted_insertions = result[3] * insertions_weight
+ weighted_deletions = result[4] * deletions_weight
+ weighted_substitutions = result[5] * substitutions_weight
+ m = result[2]
+ weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
+ return float(weighted_errors / m) if m else 0.0
diff --git a/werpy/wers.py b/werpy/wers.py
index c52a986..694b79d 100644
--- a/werpy/wers.py
+++ b/werpy/wers.py
@@ -11,6 +11,7 @@
import numpy as np
from .errorhandler import error_handler
+from .metrics import metrics
def wers(reference, hypothesis):
@@ -48,44 +49,19 @@ def wers(reference, hypothesis):
[0.0, 0.2]
"""
try:
- word_error_rate_breakdown = error_handler(reference, hypothesis)
+ error_handler(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- b = word_error_rate_breakdown
+ result = metrics(reference, hypothesis)
- # Unwrap 0-D container
- if isinstance(b, np.ndarray) and b.ndim == 0:
- b = b.item()
+ # Batch rows (n, 9)
+ if isinstance(result, np.ndarray) and result.ndim == 2:
+ return [float(x) for x in result[:, 0].tolist()]
- if isinstance(b, np.ndarray):
- if b.ndim == 2:
- # True 2-D numeric batch
- t = b.T
- wers_result = t[0].tolist()
+ # Single row
+ if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
+ result = result.item()
- elif b.ndim == 1:
- # Could be either:
- # (a) single example row vector, or
- # (b) object array of per-example vectors
- first = b[0] if b.size else None
-
- if isinstance(first, (np.ndarray, list, tuple)):
- # Batch stored as 1-D object array of per-example vectors (ragged fields exist)
- wers_result = []
- for r in b:
- rr = r.tolist() if isinstance(r, np.ndarray) else r
- wers_result.append(float(rr[0]))
- else:
- # Single example vector
- wers_result = float(b[0])
-
- else:
- raise ValueError(f"Unexpected metrics output ndim: {b.ndim}")
-
- else:
- # Non-numpy fallback (assume [wer, ld, m, ...])
- wers_result = float(b[0])
-
- return wers_result
+ return float(result[0])
From 8c3e3348c9ec130c4111325b1238cf5a9a6310a5 Mon Sep 17 00:00:00 2001
From: rossarmstrong <52817125+rossarmstrong@users.noreply.github.com>
Date: Mon, 15 Dec 2025 15:56:18 +1100
Subject: [PATCH 4/7] Update Pylint configuration to suppress Cython import
errors and ignore benchmark directories
---
.pylintrc | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/.pylintrc b/.pylintrc
index 4219ad3..8de3e8b 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -2,7 +2,15 @@
max-line-length = 120
[pylint]
-good-names=i,j,m,n,ld,df
+good-names=i,j,m,n,ld,df,ref,hyp
[MESSAGES CONTROL]
-disable=too-many-locals
\ No newline at end of file
+disable=
+ too-many-locals,
+ import-error,
+ duplicate-code
+
+[MASTER]
+# Ignore benchmark and development files that use optional dependencies
+ignore-paths=
+ ^(benchmarks|development|docs)[\\/].*$
From 1abc587565f3c580ef7bea3e4d63c85d8fbfa5ac Mon Sep 17 00:00:00 2001
From: rossarmstrong <52817125+rossarmstrong@users.noreply.github.com>
Date: Mon, 15 Dec 2025 17:07:10 +1100
Subject: [PATCH 5/7] perf(metrics): optimize Levenshtein DP loop for 7-14%
speedup
---
werpy/metrics.pyx | 55 +++++++++++++++++++++++++++++++++--------------
1 file changed, 39 insertions(+), 16 deletions(-)
diff --git a/werpy/metrics.pyx b/werpy/metrics.pyx
index 20f76e7..bab7048 100644
--- a/werpy/metrics.pyx
+++ b/werpy/metrics.pyx
@@ -36,32 +36,55 @@ cpdef cnp.ndarray calculations(object reference, object hypothesis):
cdef list hypothesis_word = hypothesis.split()
# Use Py_ssize_t for indices and sizes
+ # Py_ssize_t matches Python's internal index type and avoids unnecessary
+ # casts or overflow risks when working with Python lists and memoryviews.
cdef Py_ssize_t m = len(reference_word)
cdef Py_ssize_t n = len(hypothesis_word)
cdef Py_ssize_t i, j
- cdef int substitution_cost, ld, insertions, deletions, substitutions
+
+ # Metrics and outputs
+ cdef int ld, insertions, deletions, substitutions
+ cdef double wer
cdef list inserted_words, deleted_words, substituted_words
+ # Variables for optimized DP loop
+ cdef int cost, del_cost, ins_cost, sub_cost, best
+
# Initialize the Levenshtein distance matrix
- cdef int[:, :] ldm = np.zeros((m + 1, n + 1), dtype=np.int32)
+ # Use empty instead of zeros to avoid redundant initialization.
+ # SAFETY: All cells are explicitly initialized below (row 0, col 0, then DP loop).
+ # Allocate the (m+1) x (n+1) DP matrix without zero-initialization to avoid
+ # redundant memory writes. Boundary conditions are initialized explicitly.
+ cdef int[:, :] ldm = np.empty((m + 1, n + 1), dtype=np.int32)
- # Fill the Levenshtein distance matrix
+ # Initialize first column and first row (boundary conditions)
for i in range(m + 1):
- for j in range(n + 1):
- if i == 0:
- ldm[i, j] = j
- elif j == 0:
- ldm[i, j] = i
- else:
- substitution_cost = 0 if reference_word[i - 1] == hypothesis_word[j - 1] else 1
- ldm[i, j] = min(
- ldm[i - 1, j] + 1, # Deletion
- ldm[i, j - 1] + 1, # Insertion
- ldm[i - 1, j - 1] + substitution_cost # Substitution
- )
+ ldm[i, 0] = i
+ for j in range(n + 1):
+ ldm[0, j] = j
+
+ # Fill the Levenshtein distance matrix
+ # Compute edit distances using a branch-free inner loop and manual minimum
+ # selection to keep all operations at C level and minimize per-cell overhead.
+ # No boundary condition branches in the hot loop, manual min selection.
+ for i in range(1, m + 1):
+ for j in range(1, n + 1):
+ cost = 0 if reference_word[i - 1] == hypothesis_word[j - 1] else 1
+
+ del_cost = ldm[i - 1, j] + 1
+ ins_cost = ldm[i, j - 1] + 1
+ sub_cost = ldm[i - 1, j - 1] + cost
+
+ best = del_cost
+ if ins_cost < best:
+ best = ins_cost
+ if sub_cost < best:
+ best = sub_cost
+
+ ldm[i, j] = best
ld = ldm[m, n]
- wer = ld / m
+ wer = (ld) / m
insertions, deletions, substitutions = 0, 0, 0
inserted_words, deleted_words, substituted_words = [], [], []
From 784072eecbbe5c29331cabf4bbd4e2a6d05e45b5 Mon Sep 17 00:00:00 2001
From: rossarmstrong <52817125+rossarmstrong@users.noreply.github.com>
Date: Mon, 15 Dec 2025 21:24:55 +1100
Subject: [PATCH 6/7] perf(metrics): add fast path without word tracking for
speedup in wer/wers/werp/werps functions
---
werpy/metrics.pyx | 121 ++++++++++++++++++++++++++++++++++++++++++++--
werpy/summary.py | 3 +-
werpy/summaryp.py | 3 +-
werpy/wer.py | 16 +++---
werpy/werp.py | 39 +++++++--------
werpy/werps.py | 14 +++---
werpy/wers.py | 11 ++---
7 files changed, 159 insertions(+), 48 deletions(-)
diff --git a/werpy/metrics.pyx b/werpy/metrics.pyx
index bab7048..1689669 100644
--- a/werpy/metrics.pyx
+++ b/werpy/metrics.pyx
@@ -84,7 +84,7 @@ cpdef cnp.ndarray calculations(object reference, object hypothesis):
ldm[i, j] = best
ld = ldm[m, n]
- wer = (ld) / m
+ wer = (ld) / m if m > 0 else 0.0
insertions, deletions, substitutions = 0, 0, 0
inserted_words, deleted_words, substituted_words = [], [], []
@@ -125,7 +125,7 @@ cdef cnp.ndarray _metrics_batch(list references, list hypotheses):
[wer, ld, m, insertions, deletions, substitutions, inserted_words, deleted_words, substituted_words]
"""
cdef Py_ssize_t n = len(references)
- cdef Py_ssize_t idx, j
+ cdef Py_ssize_t idx
# Rows output, dtype=object because cols 6-8 are lists
cdef cnp.ndarray out = np.empty((n, 9), dtype=object)
@@ -138,8 +138,7 @@ cdef cnp.ndarray _metrics_batch(list references, list hypotheses):
if isinstance(r, np.ndarray) and r.ndim == 0:
r = r.item()
- for j in range(9):
- out[idx, j] = r[j]
+ out[idx, :] = r
return out
@@ -155,3 +154,117 @@ cpdef object metrics(object reference, object hypothesis):
if isinstance(reference, (list, np.ndarray)) and isinstance(hypothesis, (list, np.ndarray)):
return _metrics_batch(list(reference), list(hypothesis))
return calculations(reference, hypothesis)
+
+
+@cython.boundscheck(False)
+@cython.wraparound(False)
+cpdef cnp.ndarray calculations_fast(object reference, object hypothesis):
+ """
+ Fast path for WER/LD calculations without word tracking.
+ Returns only numeric metrics (WER, LD, m, insertions, deletions, substitutions).
+
+ This function is optimized for use cases that only need counts and metrics,
+ not the actual lists of inserted/deleted/substituted words.
+
+ Returns (6,) float64 array: [wer, ld, m, insertions, deletions, substitutions]
+ """
+ cdef list reference_word = reference.split()
+ cdef list hypothesis_word = hypothesis.split()
+
+ cdef Py_ssize_t m = len(reference_word)
+ cdef Py_ssize_t n = len(hypothesis_word)
+ cdef Py_ssize_t i, j
+
+ cdef int ld, insertions, deletions, substitutions
+ cdef double wer
+
+ cdef int cost, del_cost, ins_cost, sub_cost, best
+
+ # Allocate the (m+1) x (n+1) DP matrix without zero-initialization
+ cdef int[:, :] ldm = np.empty((m + 1, n + 1), dtype=np.int32)
+
+ # Initialize first column and first row (boundary conditions)
+ for i in range(m + 1):
+ ldm[i, 0] = i
+ for j in range(n + 1):
+ ldm[0, j] = j
+
+ # Fill the Levenshtein distance matrix
+ for i in range(1, m + 1):
+ for j in range(1, n + 1):
+ cost = 0 if reference_word[i - 1] == hypothesis_word[j - 1] else 1
+
+ del_cost = ldm[i - 1, j] + 1
+ ins_cost = ldm[i, j - 1] + 1
+ sub_cost = ldm[i - 1, j - 1] + cost
+
+ best = del_cost
+ if ins_cost < best:
+ best = ins_cost
+ if sub_cost < best:
+ best = sub_cost
+
+ ldm[i, j] = best
+
+ ld = ldm[m, n]
+ wer = (ld) / m if m > 0 else 0.0
+
+ # Backtrace to count errors (no word tracking)
+ insertions, deletions, substitutions = 0, 0, 0
+ i, j = m, n
+ while i > 0 or j > 0:
+ if i > 0 and j > 0 and reference_word[i - 1] == hypothesis_word[j - 1]:
+ i -= 1
+ j -= 1
+ else:
+ if i > 0 and j > 0 and ldm[i, j] == ldm[i - 1, j - 1] + 1:
+ substitutions += 1
+ i -= 1
+ j -= 1
+ elif j > 0 and ldm[i, j] == ldm[i, j - 1] + 1:
+ insertions += 1
+ j -= 1
+ elif i > 0 and ldm[i, j] == ldm[i - 1, j] + 1:
+ deletions += 1
+ i -= 1
+
+ return np.array(
+ [wer, ld, m,
+ insertions, deletions, substitutions],
+ dtype=np.float64
+ )
+
+
+@cython.boundscheck(False)
+@cython.wraparound(False)
+cdef cnp.ndarray _metrics_batch_fast(list references, list hypotheses):
+ """
+ Fast batch processing without word tracking.
+
+ Returns (n, 6) float64 array where each row contains:
+ [wer, ld, m, insertions, deletions, substitutions]
+ """
+ cdef Py_ssize_t n = len(references)
+ cdef Py_ssize_t idx
+
+ cdef cnp.ndarray out = np.empty((n, 6), dtype=np.float64)
+
+ cdef cnp.ndarray r
+ for idx in range(n):
+ r = calculations_fast(references[idx], hypotheses[idx])
+ out[idx, :] = r
+
+ return out
+
+
+cpdef object metrics_fast(object reference, object hypothesis):
+ """
+ Fast metrics entry point without word tracking.
+
+ Returns:
+ - strings: (6,) float64 array [wer, ld, m, insertions, deletions, substitutions]
+ - sequences: (n, 6) float64 array, one row per pair
+ """
+ if isinstance(reference, (list, np.ndarray)) and isinstance(hypothesis, (list, np.ndarray)):
+ return _metrics_batch_fast(list(reference), list(hypothesis))
+ return calculations_fast(reference, hypothesis)
diff --git a/werpy/summary.py b/werpy/summary.py
index ebbe924..b878b2c 100644
--- a/werpy/summary.py
+++ b/werpy/summary.py
@@ -53,12 +53,11 @@ def summary(reference, hypothesis) -> pd.DataFrame | None:
"""
try:
error_handler(reference, hypothesis)
+ result = metrics(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- result = metrics(reference, hypothesis)
-
# Batch rows (n, 9)
if isinstance(result, np.ndarray) and result.ndim == 2:
word_error_rate_breakdown = result.tolist()
diff --git a/werpy/summaryp.py b/werpy/summaryp.py
index 03f03fb..fa9cb93 100644
--- a/werpy/summaryp.py
+++ b/werpy/summaryp.py
@@ -67,12 +67,11 @@ def summaryp(
"""
try:
error_handler(reference, hypothesis)
+ result = metrics(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- result = metrics(reference, hypothesis)
-
# Batch rows (n, 9)
if isinstance(result, np.ndarray) and result.ndim == 2:
word_error_rate_breakdown = result.tolist()
diff --git a/werpy/wer.py b/werpy/wer.py
index 3e0489b..f9e116e 100644
--- a/werpy/wer.py
+++ b/werpy/wer.py
@@ -12,7 +12,7 @@
import numpy as np
from .errorhandler import error_handler
-from .metrics import metrics
+from .metrics import metrics_fast
def wer(reference, hypothesis) -> float | np.float64 | None:
@@ -57,20 +57,18 @@ def wer(reference, hypothesis) -> float | np.float64 | None:
"""
try:
error_handler(reference, hypothesis)
+ result = metrics_fast(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- result = metrics(reference, hypothesis)
-
- # Batch rows (n, 9)
+ # Batch: (n, 6) float64
if isinstance(result, np.ndarray) and result.ndim == 2:
- ld_total = float(np.sum(result[:, 1]))
- m_total = float(np.sum(result[:, 2]))
- return ld_total / m_total
+ den = np.sum(result[:, 2])
+ return float(np.sum(result[:, 1]) / den) if den else 0.0
- # Single row
+ # Single: (6,) float64, WER is at index 0
if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
result = result.item()
- return float(result[1]) / float(result[2])
+ return float(result[0])
diff --git a/werpy/werp.py b/werpy/werp.py
index 2cdef59..4618ce4 100644
--- a/werpy/werp.py
+++ b/werpy/werp.py
@@ -11,7 +11,7 @@
import numpy as np
from .errorhandler import error_handler
-from .metrics import metrics
+from .metrics import metrics_fast
def werp(
@@ -75,27 +75,28 @@ def werp(
"""
try:
error_handler(reference, hypothesis)
+ result = metrics_fast(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- result = metrics(reference, hypothesis)
-
- # Batch rows (n, 9)
+ # Batch: (n, 6) float64
if isinstance(result, np.ndarray) and result.ndim == 2:
- weighted_insertions = np.sum(result[:, 3]) * insertions_weight
- weighted_deletions = np.sum(result[:, 4]) * deletions_weight
- weighted_substitutions = np.sum(result[:, 5]) * substitutions_weight
- m = np.sum(result[:, 2])
- else:
- # Single row
- if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
- result = result.item()
- weighted_insertions = result[3] * insertions_weight
- weighted_deletions = result[4] * deletions_weight
- weighted_substitutions = result[5] * substitutions_weight
- m = result[2]
-
+ weighted_insertions = result[:, 3] * insertions_weight
+ weighted_deletions = result[:, 4] * deletions_weight
+ weighted_substitutions = result[:, 5] * substitutions_weight
+ m = result[:, 2]
+ weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
+ den = np.sum(m)
+ return float(np.sum(weighted_errors) / den) if den else 0.0
+
+ # Single: (6,) float64
+ if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
+ result = result.item()
+
+ weighted_insertions = result[3] * insertions_weight
+ weighted_deletions = result[4] * deletions_weight
+ weighted_substitutions = result[5] * substitutions_weight
+ m = result[2]
weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
- werp_result = float(weighted_errors / m) if m else 0.0
- return werp_result
+ return float(weighted_errors / m) if m else 0.0
diff --git a/werpy/werps.py b/werpy/werps.py
index 6e07584..8454226 100644
--- a/werpy/werps.py
+++ b/werpy/werps.py
@@ -11,7 +11,7 @@
import numpy as np
from .errorhandler import error_handler
-from .metrics import metrics
+from .metrics import metrics_fast
def werps(
@@ -69,22 +69,24 @@ def werps(
"""
try:
error_handler(reference, hypothesis)
+ result = metrics_fast(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- result = metrics(reference, hypothesis)
-
- # Batch rows (n, 9)
+ # Batch: (n, 6) float64
if isinstance(result, np.ndarray) and result.ndim == 2:
weighted_insertions = result[:, 3] * insertions_weight
weighted_deletions = result[:, 4] * deletions_weight
weighted_substitutions = result[:, 5] * substitutions_weight
m = result[:, 2]
weighted_errors = weighted_insertions + weighted_deletions + weighted_substitutions
- return (weighted_errors / m).tolist()
+ out = np.zeros_like(weighted_errors, dtype=np.float64)
+ mask = m != 0
+ out[mask] = weighted_errors[mask] / m[mask]
+ return out.tolist()
- # Single row
+ # Single: (6,) float64
if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
result = result.item()
diff --git a/werpy/wers.py b/werpy/wers.py
index 694b79d..dea771e 100644
--- a/werpy/wers.py
+++ b/werpy/wers.py
@@ -11,7 +11,7 @@
import numpy as np
from .errorhandler import error_handler
-from .metrics import metrics
+from .metrics import metrics_fast
def wers(reference, hypothesis):
@@ -50,17 +50,16 @@ def wers(reference, hypothesis):
"""
try:
error_handler(reference, hypothesis)
+ result = metrics_fast(reference, hypothesis)
except (ValueError, AttributeError, ZeroDivisionError) as err:
print(f"{type(err).__name__}: {str(err)}")
return None
- result = metrics(reference, hypothesis)
-
- # Batch rows (n, 9)
+ # Batch: (n, 6) float64
if isinstance(result, np.ndarray) and result.ndim == 2:
- return [float(x) for x in result[:, 0].tolist()]
+ return result[:, 0].tolist()
- # Single row
+ # Single: (6,) float64, WER is at index 0
if isinstance(result, np.ndarray) and getattr(result, "ndim", 0) == 0:
result = result.item()
From 15a99ea120d09da3f9d12725df54a6b1da48e7b8 Mon Sep 17 00:00:00 2001
From: rossarmstrong <52817125+rossarmstrong@users.noreply.github.com>
Date: Mon, 15 Dec 2025 21:57:50 +1100
Subject: [PATCH 7/7] docs(changelog): document dual-path architecture,
performance improvements, and exception handling fixes
---
CHANGELOG.md | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23b3a00..c8e18ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,33 @@
This changelog file outlines a chronologically ordered list of the changes made on this project.
It is organized by version and release date followed by a list of Enhancements, New Features, Bug Fixes, and/or Breaking Changes.
+## Version 3.2.0
+
+**Released:** December 15, 2025
+**Tag:** v3.2.0
+
+### Enhancements
+
+- Refactored metrics computation architecture to eliminate `np.vectorize()` overhead by replacing it with a C-level batch processing loop (`_metrics_batch()`). This provides cleaner code structure and establishes a foundation for future performance optimizations without introducing any performance regression.
+
+- Fixed double computation bug where `error_handler()` was calling `metrics()` for validation, then wrapper functions were computing metrics again. The `error_handler()` function is now validation-only, and all metric calculations happen through a single unified `metrics()` entry point, improving efficiency and code maintainability.
+
+- Standardized internal metrics return format to row-based `(n, 9)` array structure instead of columnar format. This simplifies DataFrame construction in `summary()` and `summaryp()` functions by eliminating complex transpose operations and reducing code complexity.
+
+- Improved code organization with unified `metrics()` router function that dispatches to either single-pair `calculations()` or batch `_metrics_batch()` processing, providing a cleaner and more maintainable architecture for metric computation.
+
+- Updated Pylint configuration to suppress import errors for Cython modules during static analysis and exclude benchmark/development directories from linting. This resolves CI/CD build failures while maintaining code quality standards for the core package.
+
+- Optimized Levenshtein distance algorithm in `calculations()` function with C-level performance improvements: replaced `np.zeros()` with `np.empty()` to eliminate redundant initialization, moved boundary condition initialization outside the main DP loop to remove conditional branches from the hot path, and replaced Python's `min()` function with manual C-level sequential comparisons.
+
+- Implemented dual-path architecture with fast path optimization for functions that don't require word tracking. Added three new functions (`calculations_fast()`, `_metrics_batch_fast()`, `metrics_fast()`) that skip word list construction and return float64 arrays instead of object arrays. Updated `wer()`, `wers()`, `werp()`, and `werps()` functions to use the fast path, achieving performance improvement on synthetic benchmarks. Functions requiring word tracking (`summary()` and `summaryp()`) continue using the full path.
+
+### Bug Fixes
+
+- Expanded try/except scope in all wrapper functions (`wer.py`, `wers.py`, `werp.py`, `werps.py`, `summary.py`, `summaryp.py`) to properly catch exceptions from both validation (`error_handler()`) and computation (`metrics()`/`metrics_fast()`). This fixes 6 pre-existing test failures where invalid input types (e.g., lists of integers) would crash instead of returning None with an error message.
+
+- Added division-by-zero guards in `calculations_fast()` function (`wer = (ld) / m if m > 0 else 0.0`) and corpus-level wrapper functions (`wer.py`, `werp.py`) to prevent crashes on empty input. Also added per-row masked division in `werps.py` to handle cases where individual samples have zero reference length.
+
## Version 3.1.1
**Released:** December 14, 2025