fix: support NumPy 2 with verified scientific-stack compatibility (#3674)

Retain the contributor alias cleanup and remove the NumPy cap with verified CPU dependency, frontend, masking and clustering coverage.

NumPy 1.26.4 and 2.4.0 each pass 23 exact-head CI tests and 77 local tests plus 184 subtests. Existing EEND mel calls now use keyword arguments for modern librosa.

The related installation issue remains open pending published-package and reporter re-test.

Co-authored-by: linhongyu510 <[email protected]>
Signed-off-by: zhifu gao <[email protected]>
This commit is contained in:
hylin
2026-09-09 10:59:37 +08:00
committed by GitHub
parent 01e822be15
commit 28fa5a4d64
10 changed files with 421 additions and 14 deletions
@@ -0,0 +1,84 @@
name: Validate NumPy compatibility
on:
pull_request:
paths:
- "setup.py"
- "funasr/__init__.py"
- "funasr/frontends/**"
- "funasr/models/emotion2vec/**"
- "funasr/models/campplus/**"
- "funasr/models/eend/utils/feature.py"
- "tests/test_numpy_compatibility.py"
- "tests/test_numpy_cluster_compatibility.py"
- "tests/test_eend_feature_compatibility.py"
- "tests/test_pypi_metadata.py"
- ".github/workflows/test-numpy-compatibility.yml"
push:
branches: [main]
paths:
- "setup.py"
- "funasr/__init__.py"
- "funasr/frontends/**"
- "funasr/models/emotion2vec/**"
- "funasr/models/campplus/**"
- "funasr/models/eend/utils/feature.py"
- "tests/test_numpy_compatibility.py"
- "tests/test_numpy_cluster_compatibility.py"
- "tests/test_eend_feature_compatibility.py"
- "tests/test_pypi_metadata.py"
- ".github/workflows/test-numpy-compatibility.yml"
permissions:
contents: read
jobs:
numpy-compatibility:
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
numpy: ["1.26.4", "2.4.0"]
env:
OMP_NUM_THREADS: "1"
MKL_NUM_THREADS: "1"
NUMBA_NUM_THREADS: "1"
HF_HUB_OFFLINE: "1"
NUMPY_VERSION: ${{ matrix.numpy }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install CPU runtime and requested NumPy version
run: |
python -m pip install torch==2.10.0 torchaudio==2.10.0 --index-url https://download.pytorch.org/whl/cpu
python -m pip install "numpy==$NUMPY_VERSION" -e . pytest einops pytorch-wpe --report install-report.json
- name: Record resolved environment and validate dependencies
run: |
git rev-parse HEAD | tee source-commit.txt
python -c "import numpy, os; assert numpy.__version__ == os.environ['NUMPY_VERSION']"
python -m pip freeze | tee resolved-dependencies.txt
python -m pip check | tee pip-check.txt
- name: Exercise real frontend, masking, import and array boundaries
run: |
python -m pytest -q \
tests/test_numpy_compatibility.py \
tests/test_numpy_cluster_compatibility.py \
tests/test_eend_feature_compatibility.py \
tests/test_pypi_metadata.py \
--junitxml=numpy-results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: numpy-${{ matrix.numpy }}-evidence
path: |
resolved-dependencies.txt
pip-check.txt
numpy-results.xml
source-commit.txt
install-report.json
+2 -2
View File
@@ -411,6 +411,6 @@ class MultiChannelFrontend(nn.Module):
rescale_line = line_item[3 : (len(line_item) - 1)]
vars_list = list(rescale_line)
continue
means = np.array(means_list).astype(np.float)
vars = np.array(vars_list).astype(np.float)
means = np.array(means_list).astype(np.float64)
vars = np.array(vars_list).astype(np.float64)
return means, vars
+1 -1
View File
@@ -18,7 +18,7 @@ def transform(Y, dtype=np.float32):
n_fft = 2 * (Y.shape[1] - 1)
sr = 8000
n_mels = 23
mel_basis = librosa.filters.mel(sr, n_fft, n_mels)
mel_basis = librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels)
Y = np.dot(Y**2, mel_basis.T)
Y = np.log10(np.maximum(Y, 1e-10))
mean = np.mean(Y, axis=0)
+6 -6
View File
@@ -35,7 +35,7 @@ def transform(Y, transform_type=None, dtype=np.float32):
Args:
Y: STFT
(n_frames, n_bins)-shaped np.complex array
(n_frames, n_bins)-shaped complex array
transform_type:
None, "log"
dtype: output data type
@@ -52,21 +52,21 @@ def transform(Y, transform_type=None, dtype=np.float32):
n_fft = 2 * (Y.shape[1] - 1)
sr = 16000
n_mels = 40
mel_basis = librosa.filters.mel(sr, n_fft, n_mels)
mel_basis = librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels)
Y = np.dot(Y**2, mel_basis.T)
Y = np.log10(np.maximum(Y, 1e-10))
elif transform_type == "logmel23":
n_fft = 2 * (Y.shape[1] - 1)
sr = 8000
n_mels = 23
mel_basis = librosa.filters.mel(sr, n_fft, n_mels)
mel_basis = librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels)
Y = np.dot(Y**2, mel_basis.T)
Y = np.log10(np.maximum(Y, 1e-10))
elif transform_type == "logmel23_mn":
n_fft = 2 * (Y.shape[1] - 1)
sr = 8000
n_mels = 23
mel_basis = librosa.filters.mel(sr, n_fft, n_mels)
mel_basis = librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels)
Y = np.dot(Y**2, mel_basis.T)
Y = np.log10(np.maximum(Y, 1e-10))
mean = np.mean(Y, axis=0)
@@ -75,7 +75,7 @@ def transform(Y, transform_type=None, dtype=np.float32):
n_fft = 2 * (Y.shape[1] - 1)
sr = 8000
n_mels = 23
mel_basis = librosa.filters.mel(sr, n_fft, n_mels)
mel_basis = librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels)
Y = np.dot(Y**2, mel_basis.T)
Y = np.log10(np.maximum(Y, 1e-10))
# b = np.ones(300)/300
@@ -92,7 +92,7 @@ def transform(Y, transform_type=None, dtype=np.float32):
n_fft = 2 * (Y.shape[1] - 1)
sr = 8000
n_mels = 23
mel_basis = librosa.filters.mel(sr, n_fft, n_mels)
mel_basis = librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels)
Y = np.dot(Y**2, mel_basis.T)
Y = np.log10(np.maximum(Y, 1e-10))
mean = np.mean(Y, axis=0)
+1 -1
View File
@@ -324,7 +324,7 @@ def compute_mask_indices(
for length in sorted(lengths, reverse=True):
lens = np.fromiter(
(e - s if e - s >= length + min_space else 0 for s, e in parts),
np.int,
np.int64,
)
l_sum = np.sum(lens)
if l_sum == 0:
+3 -2
View File
@@ -14,8 +14,9 @@ requirements = {
"scipy>=1.4.1",
"librosa",
"soundfile>=0.12.1",
# Keep the scientific stack ABI-compatible with the supported SciPy wheels.
"numpy<2",
# numpy 2.x is supported; deprecated aliases (np.float, np.int) were
# removed from call sites so the package imports and runs on both 1.x and 2.x.
"numpy",
"PyYAML>=5.1.2",
"tqdm",
"requests",
+68
View File
@@ -0,0 +1,68 @@
"""Check EEND feature values across the supported scientific-stack matrix."""
import numpy as np
import pytest
import torch
@pytest.fixture
def spectra():
amplitudes = np.array([1, 10, 100, 1000], dtype=np.float32)
return amplitudes[:, None] * np.full((4, 257), 1 + 1j, dtype=np.complex64)
@pytest.mark.parametrize("mode,bands", [("logmel", 40), ("logmel23", 23)])
def test_eend_logmel_preserves_power_ratios(spectra, mode, bands):
from funasr.models.eend.utils.feature import transform
result = transform(spectra, transform_type=mode)
assert result.dtype == np.float32 and result.shape == (4, bands)
assert np.isfinite(result).all()
# A tenfold amplitude increase adds 2 to log10 power in every mel band.
np.testing.assert_allclose(np.diff(result, axis=0), 2.0, atol=2e-6)
@pytest.mark.parametrize(
"mode,expected",
[
("logmel23_mn", [-3, -1, 1, 3]),
("logmel23_mvn", np.array([-3, -1, 1, 3]) / np.sqrt(5)),
("logmel23_swn", [-5, -3, -1, 1]),
],
)
def test_eend_normalization_values(spectra, mode, expected):
from funasr.models.eend.utils.feature import transform
original = spectra.copy()
result = transform(spectra, transform_type=mode)
assert result.dtype == np.float32
np.testing.assert_allclose(
result, np.repeat(np.asarray(expected)[:, None], 23, axis=1), atol=2e-6
)
np.testing.assert_array_equal(spectra, original)
def test_eend_ola_mean_normalization(spectra):
from funasr.frontends.eend_ola_feature import transform
result = transform(spectra)
np.testing.assert_allclose(
result, np.repeat(np.array([-3, -1, 1, 3])[:, None], 23, axis=1), atol=2e-6
)
assert result.dtype == np.float32
def test_mel23_waveform_frontend_returns_finite_padded_features():
from funasr.frontends.wav_frontend import WavFrontendMel23
wave = np.sin(2 * np.pi * 440 * np.arange(1600) / 8000).astype(np.float32)
samples = torch.from_numpy(np.stack([wave, wave]))
frontend = WavFrontendMel23(
fs=8000, frame_length=256, frame_shift=80, lfr_m=0, lfr_n=1
)
features, lengths = frontend(samples, torch.tensor([1600, 1200]))
assert features.shape == (2, 20, 23)
assert lengths.tolist() == [20, 15]
assert features.dtype == torch.float32 and torch.isfinite(features).all()
assert torch.count_nonzero(features[1, 15:]) == 0
assert torch.count_nonzero(features[0]) > 0
+81
View File
@@ -0,0 +1,81 @@
"""Model-free compatibility checks for the production speaker clustering paths."""
import numpy as np
import pytest
from funasr.models.campplus.cluster_backend import ClusterBackend, UmapHdbscan
@pytest.fixture(autouse=True)
def preserve_numpy_random_state():
# The production sklearn/UMAP calls use NumPy's global random state.
state = np.random.get_state()
np.random.seed(17)
try:
yield
finally:
np.random.set_state(state)
def _speaker_embeddings(num_speakers, dtype, samples_per_speaker=24):
rng = np.random.default_rng(20260909)
expected = np.repeat(np.arange(num_speakers), samples_per_speaker)
centers = np.eye(num_speakers, 8)
embeddings = centers[expected] + rng.normal(0.0, 0.015, (len(expected), 8))
embeddings /= np.linalg.norm(embeddings, axis=1, keepdims=True)
order = rng.permutation(len(expected))
return embeddings[order].astype(dtype), expected[order]
def _assert_same_partition(labels, expected):
labels = np.asarray(labels)
assert labels.shape == expected.shape
assert np.issubdtype(labels.dtype, np.integer)
assert np.all(labels >= 0), "Separated speakers must not be classified as noise"
np.testing.assert_array_equal(
labels[:, None] == labels[None, :],
expected[:, None] == expected[None, :],
)
@pytest.mark.parametrize("dtype", [np.float32, np.float64])
@pytest.mark.parametrize("num_speakers", [2, 3])
def test_spectral_partition_survives_dtype_and_input_order(dtype, num_speakers):
embeddings, expected = _speaker_embeddings(num_speakers, dtype)
original = embeddings.copy()
# This range selects the real spectral branch, not the <20 zero-label path.
assert 20 <= len(embeddings) < 2048
backend = ClusterBackend()
labels = backend(embeddings, oracle_num=num_speakers)
_assert_same_partition(labels, expected)
permutation = np.random.default_rng(91).permutation(len(embeddings))
permuted_labels = backend(embeddings[permutation], oracle_num=num_speakers)
_assert_same_partition(permuted_labels, expected[permutation])
np.testing.assert_array_equal(embeddings, original)
def test_spectral_oracle_can_override_two_natural_speakers():
embeddings, expected = _speaker_embeddings(2, np.float32)
assert np.unique(expected).size == 2
labels = ClusterBackend()(embeddings, oracle_num=1)
_assert_same_partition(labels, np.zeros_like(expected))
def test_umap_hdbscan_preserves_separated_speakers():
import numba
embeddings, expected = _speaker_embeddings(2, np.float32, samples_per_speaker=32)
original = embeddings.copy()
# Exercise the real wrapper on a small fixture, not the >=2048 dispatcher.
cluster = UmapHdbscan(
n_neighbors=10, n_components=2, min_samples=3, min_cluster_size=12
)
previous_threads = numba.get_num_threads()
numba.set_num_threads(1)
try:
labels = cluster(embeddings)
finally:
numba.set_num_threads(previous_threads)
_assert_same_partition(labels, expected)
np.testing.assert_array_equal(embeddings, original)
+151
View File
@@ -0,0 +1,151 @@
"""Exercise FunASR's NumPy boundaries without downloading model weights."""
import json
import os
from pathlib import Path
import subprocess
import sys
import numpy as np
import pytest
import torch
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def cmvn_path(tmp_path):
path = tmp_path / "frontend.cmvn"
path.write_text(
"<AddShift>\n<LearnRateCoef> 0 0 "
+ " ".join(["0.1"] * 8)
+ " </LearnRateCoef>\n</AddShift>\n"
+ "<Rescale>\n<LearnRateCoef> 0 0 "
+ " ".join(["1.25"] * 8)
+ " </LearnRateCoef>\n</Rescale>\n",
encoding="utf-8",
)
return path
def test_cmvn_load_returns_float64_arrays(cmvn_path):
from funasr.frontends.default import MultiChannelFrontend
means, scales = MultiChannelFrontend._load_cmvn(None, cmvn_path)
assert means.dtype == scales.dtype == np.float64
np.testing.assert_array_equal(means, np.full(8, 0.1, dtype=np.float64))
np.testing.assert_array_equal(scales, np.full(8, 1.25, dtype=np.float64))
def test_frontend_applies_cmvn_and_preserves_padding(cmvn_path):
from funasr.frontends.default import MultiChannelFrontend
kwargs = dict(
fs=16000,
n_fft=512,
frame_length=25,
frame_shift=10,
n_mels=8,
center=False,
mc=False,
)
plain = MultiChannelFrontend(**kwargs).eval()
normalized = MultiChannelFrontend(**kwargs, cmvn_file=str(cmvn_path)).eval()
# Initialization crosses the NumPy -> Torch boundary in production.
assert normalized.mean.dtype == normalized.std.dtype == torch.float64
np.testing.assert_array_equal(normalized.mean.numpy(), np.full(8, 0.1))
wave = np.sin(2 * np.pi * 440 * np.arange(1600) / 16000).astype(np.float32)
samples = torch.from_numpy(np.stack([wave, wave]))
samples[1, 1200:] = 0
lengths = torch.tensor([1600, 1200])
with torch.no_grad():
before, before_lengths = plain(samples, lengths)
after, after_lengths = normalized(samples, lengths)
torch.testing.assert_close(after_lengths, before_lengths)
assert after.shape == before.shape and after.shape[-1] == 8
assert torch.isfinite(after).all()
for row, length in enumerate(after_lengths.tolist()):
assert length > 0
torch.testing.assert_close(
after[row, :length], (before[row, :length] + 0.1) * 1.25
)
assert torch.count_nonzero(after[row, length:]) == 0
@pytest.mark.parametrize("seed", [0, 1, 7])
def test_emotion2vec_no_overlap_respects_padding_and_spacing(seed):
from funasr.models.emotion2vec.fairseq_modules import compute_mask_indices
padding = torch.zeros((2, 80), dtype=torch.bool)
padding[1, 53:] = True
previous = np.random.get_state()
try:
np.random.seed(seed)
mask = compute_mask_indices(
(2, 80),
padding,
mask_prob=0.45,
mask_length=4,
no_overlap=True,
min_space=2,
require_same_masks=False,
)
finally:
np.random.set_state(previous)
assert mask.shape == (2, 80) and mask.dtype == np.bool_
assert not mask[padding.numpy()].any()
for row in mask:
indices = np.flatnonzero(row)
assert len(indices) > 0
spans = np.split(indices, np.flatnonzero(np.diff(indices) > 1) + 1)
assert all(len(span) == 4 for span in spans)
assert all(
right[0] - left[-1] - 1 >= 2 for left, right in zip(spans, spans[1:])
)
# Convert the real mask, not a duplicate np.fromiter expression.
tensor_mask = torch.from_numpy(mask)
assert tensor_mask.dtype == torch.bool
np.testing.assert_array_equal(tensor_mask.numpy(), mask)
def test_relevant_modules_import_in_a_fresh_process():
modules = [
"funasr.frontends.default",
"funasr.frontends.wav_frontend",
"funasr.models.emotion2vec.fairseq_modules",
"funasr.models.eend.utils.feature",
"funasr.auto.auto_model",
]
code = """
import importlib, json, pathlib, sys
root = pathlib.Path(sys.argv[1]).resolve()
names = json.loads(sys.argv[2])
loaded = {}
for name in names:
module = importlib.import_module(name)
path = pathlib.Path(module.__file__).resolve()
assert path.is_relative_to(root), (name, str(path))
loaded[name] = str(path)
import funasr
errors = funasr.get_import_errors()
assert not {name: errors[name] for name in names if name in errors}, errors
print('NUMPY_IMPORT_RESULT=' + json.dumps(loaded))
"""
env = os.environ.copy()
env.update({"PYTHONPATH": str(ROOT), "HF_HUB_OFFLINE": "1", "OMP_NUM_THREADS": "1"})
result = subprocess.run(
[sys.executable, "-c", code, str(ROOT), json.dumps(modules)],
cwd=ROOT,
env=env,
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, result.stdout + result.stderr
payload = next(
line.removeprefix("NUMPY_IMPORT_RESULT=")
for line in result.stdout.splitlines()
if line.startswith("NUMPY_IMPORT_RESULT=")
)
assert set(json.loads(payload)) == set(modules)
+24 -2
View File
@@ -34,7 +34,29 @@ def test_package_keywords_surface_deployment_discovery_terms():
assert keyword in text
def test_core_dependencies_prevent_numpy_two_abi_mismatches():
def test_core_dependencies_allow_numpy_two():
"""numpy must not be pinned to <2; the codebase is compatible with both 1.x and 2.x."""
text = _setup_text()
assert '"numpy<2"' in text
assert '"numpy<2"' not in text
assert '"numpy"' in text
def test_no_removed_numpy_aliases_in_source():
"""np.float / np.int / etc. were removed in numpy 1.24 and must not appear in funasr code."""
import re
removed_aliases = re.compile(
r"\bnp\.(float|int|bool|object|str|complex|long|unicode)\b(?!\d|_)"
)
source_files = list((ROOT / "funasr").rglob("*.py"))
offenders: list[str] = []
for path in source_files:
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
# Skip docstrings/comments: only flag lines that are actual code
stripped = line.strip()
if stripped.startswith("#") or stripped.startswith('"') or stripped.startswith("'"):
continue
if removed_aliases.search(line):
offenders.append(f"{path.relative_to(ROOT)}:{lineno}: {stripped}")
assert not offenders, f"Removed numpy aliases found:\n" + "\n".join(offenders)