From 28fa5a4d6412002601aabba0f3b3218d74c00c96 Mon Sep 17 00:00:00 2001 From: hylin Date: Wed, 9 Sep 2026 10:59:37 +0800 Subject: [PATCH] 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 Signed-off-by: zhifu gao --- .../workflows/test-numpy-compatibility.yml | 84 ++++++++++ funasr/frontends/default.py | 4 +- funasr/frontends/eend_ola_feature.py | 2 +- funasr/models/eend/utils/feature.py | 12 +- funasr/models/emotion2vec/fairseq_modules.py | 2 +- setup.py | 5 +- tests/test_eend_feature_compatibility.py | 68 ++++++++ tests/test_numpy_cluster_compatibility.py | 81 ++++++++++ tests/test_numpy_compatibility.py | 151 ++++++++++++++++++ tests/test_pypi_metadata.py | 26 ++- 10 files changed, 421 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/test-numpy-compatibility.yml create mode 100644 tests/test_eend_feature_compatibility.py create mode 100644 tests/test_numpy_cluster_compatibility.py create mode 100644 tests/test_numpy_compatibility.py diff --git a/.github/workflows/test-numpy-compatibility.yml b/.github/workflows/test-numpy-compatibility.yml new file mode 100644 index 000000000..bdaaf26ec --- /dev/null +++ b/.github/workflows/test-numpy-compatibility.yml @@ -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 diff --git a/funasr/frontends/default.py b/funasr/frontends/default.py index 8ef283d40..67958cafe 100644 --- a/funasr/frontends/default.py +++ b/funasr/frontends/default.py @@ -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 diff --git a/funasr/frontends/eend_ola_feature.py b/funasr/frontends/eend_ola_feature.py index aead92daf..0ce98df81 100644 --- a/funasr/frontends/eend_ola_feature.py +++ b/funasr/frontends/eend_ola_feature.py @@ -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) diff --git a/funasr/models/eend/utils/feature.py b/funasr/models/eend/utils/feature.py index d62bb4104..edb8fb8cb 100644 --- a/funasr/models/eend/utils/feature.py +++ b/funasr/models/eend/utils/feature.py @@ -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) diff --git a/funasr/models/emotion2vec/fairseq_modules.py b/funasr/models/emotion2vec/fairseq_modules.py index 3f7887225..f7f31c51e 100644 --- a/funasr/models/emotion2vec/fairseq_modules.py +++ b/funasr/models/emotion2vec/fairseq_modules.py @@ -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: diff --git a/setup.py b/setup.py index b90a09030..762784d1b 100644 --- a/setup.py +++ b/setup.py @@ -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", diff --git a/tests/test_eend_feature_compatibility.py b/tests/test_eend_feature_compatibility.py new file mode 100644 index 000000000..db3f4c257 --- /dev/null +++ b/tests/test_eend_feature_compatibility.py @@ -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 diff --git a/tests/test_numpy_cluster_compatibility.py b/tests/test_numpy_cluster_compatibility.py new file mode 100644 index 000000000..6389847c1 --- /dev/null +++ b/tests/test_numpy_cluster_compatibility.py @@ -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) diff --git a/tests/test_numpy_compatibility.py b/tests/test_numpy_compatibility.py new file mode 100644 index 000000000..ff194b745 --- /dev/null +++ b/tests/test_numpy_compatibility.py @@ -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( + "\n 0 0 " + + " ".join(["0.1"] * 8) + + " \n\n" + + "\n 0 0 " + + " ".join(["1.25"] * 8) + + " \n\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) diff --git a/tests/test_pypi_metadata.py b/tests/test_pypi_metadata.py index f45c1e998..42f49b9c8 100644 --- a/tests/test_pypi_metadata.py +++ b/tests/test_pypi_metadata.py @@ -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)