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