From e5d11deaa47d8b622ef6a1fc9d018ad830f5d503 Mon Sep 17 00:00:00 2001 From: mciach Date: Mon, 28 Sep 2026 12:10:41 +0200 Subject: [PATCH 01/65] nwgrad --- RNA_simulated_general_scheme.ipynb | 2 +- src/discrimalign.py | 280 +++++++++++++++++++++++++++++ src/logit_link.py | 3 + 3 files changed, 284 insertions(+), 1 deletion(-) diff --git a/RNA_simulated_general_scheme.ipynb b/RNA_simulated_general_scheme.ipynb index dbd9037..5ea267c 100644 --- a/RNA_simulated_general_scheme.ipynb +++ b/RNA_simulated_general_scheme.ipynb @@ -244,7 +244,7 @@ "source": [ "const_step = create_constant_step(0.00005)\n", "# powerstep = create_powerstep(0.00005, power=0.5, burnin=0)\n", - "powerstep = create_powerstep(0.00002, power=-0.1, burnin=0)" + "# powerstep = create_powerstep(0.00002, power=-0.1, burnin=0)" ] }, { diff --git a/src/discrimalign.py b/src/discrimalign.py index c1f9d06..69decdf 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -345,3 +345,283 @@ def alpha_fsec(alpha): # results['loglik_expectation_trajectory'] = loglik_expectation # results['loglik_sd_trajectory'] = loglik_sd return results + +def discrimalign_nwgrad(seqlistA, seqlistB, + labels, + baseline_params=None, + aligner_mode='local', + gap_mode='affine', + substitution_mode='symmetric', + alphabet=None, + stochastic_factor=None, + stepfunction=None, + max_iter=1000, tol=1e-3, + num_threads=1, + subgradient_scale=1.0, + initial_parameters=None, + return_alignments=True, + verbose=False): + import nwgrad + assert aligner_mode in {'local', 'global'} + assert gap_mode in {'affine', 'linear'} + assert substitution_mode in {'general', 'symmetric', 'simple'} + if alphabet is None: + charsetA = set(char for seq in seqlistA for char in seq) + charsetB = set(char for seq in seqlistB for char in seq) + alphabet = charsetA | charsetB + alphabet = ''.join(sorted(alphabet)) + + if verbose: + print('Alphabet:') + print(alphabet) + + # Stochastic trick function + if stochastic_factor is None: + def add_noise(shape, niter): + if shape == 1: + return 0. + else: + return np.zeros(shape) + # Note: For consistency with numpy, returning a number should + # happen when shape == None, shape 1 should return an array. + # This would require modifying the code elsewhere + else: + def add_noise(shape, niter): + if shape == 1: + return rd.normal(scale=stochastic_factor/(niter+1)) + else: + return rd.normal(size=shape, scale=stochastic_factor/(niter+1)) + + # Initial alignments + if baseline_params is not None: + baseline_params = deepcopy(baseline_params) + else: + subsmat = np.eye(len(alphabet))*(5+4) - 4 + subsmat = nwgrad.SubstMatrix(subsmat, alphabet=alphabet) + if gap_mode == 'affine': + gapopen = -10 + gapextend = -0.5 + elif gap_mode == 'linear': + gapopen = -6 + gapextend = -6 + params = nwgrad.AlignParams( + subsmat, + gap_open_a=gapopen, gap_extend_a=gapextend, + gap_open_b=gapopen, gap_extend_b=gapextend, + ) + seqpairs = [ + nwgrad.SeqPair(a, b, params, + gap_model=gap_mode, mode=aligner_mode) + for a, b in zip(seqlistA, seqlistB) + ] + batch = nwgrad.SeqPairBatch(n_threads=8) + for sp in pairs: + batch.add(sp) + total_log_z = batch.score() + alignment_scores = [aln.score for aln in alnlist] + + # Initial logistic estimation, unless caller provides fitted parameters for a warm start. + if initial_parameters is not None: + updated_parameters = deepcopy(initial_parameters) + else: + updated_parameters = get_initial_estimate(alnlist, labels, + substitution_mode=substitution_mode, + gap_mode=gap_mode, + alphabet=alphabet) + if verbose: + print('Initial parameters:') + print(updated_parameters) + if gap_mode == 'affine': + aligner.open_gap_score = updated_parameters['open_gap_score'] + aligner.extend_gap_score = updated_parameters['extend_gap_score'] + else: + aligner.gap_score = updated_parameters['gap_score'] + + if substitution_mode == 'simple': + aligner.match_score = updated_parameters['match_score'] + aligner.mismatch_score = updated_parameters['mismatch_score'] + else: + aligner.substitution_matrix = updated_parameters['substitution_matrix'] + + # Subgradient refinement + loglik_trajectory = [] + subgradient_l2_trajectory = [] + loglik_expectation = [] + loglik_sd = [] + for iternb in range(max_iter): + if verbose: + print('Start of iteration', iternb) + # Set new aligner parameters + if gap_mode == 'affine': + aligner.open_gap_score = updated_parameters['open_gap_score'] + aligner.extend_gap_score = updated_parameters['extend_gap_score'] + elif gap_mode == 'linear': + aligner.gap_score = updated_parameters['gap_score'] + + if substitution_mode == 'simple': + aligner.match_score = updated_parameters['match_score'] + aligner.mismatch_score = updated_parameters['mismatch_score'] + else: + aligner.substitution_matrix = updated_parameters['substitution_matrix'] + + # Realign with the new parameters + alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) + alignment_scores = [aln.score for aln in alnlist] + logit_scores = logit_partial_scores(alignment_scores, + updated_parameters['alpha']) + new_logL = logit_logL(logit_scores, labels) + loglik_trajectory.append(new_logL) + if verbose: + print("Current alpha:", updated_parameters['alpha']) + print('Current logL:', new_logL) +## EL = 0 +## VL = 0 +## for ls in logit_scores: +## if 1e-30 < ls < 1-1e-30: +## EL += ls*np.log(ls) + (1-ls)*np.log(1-ls) +## VL += ls*(1-ls)*(np.log(ls)**2 + np.log(1-ls)**2) +## SDL = np.sqrt(VL) +## loglik_expectation.append(EL) +## loglik_sd.append(SDL) + + # Optimize the logistic intercept (alpha) + def alpha_target(alpha): + logit_scores = logit_partial_scores(alignment_scores, alpha) + return -logit_logL(logit_scores, labels) + + def alpha_fprime(alpha): + logit_scores = logit_partial_scores(alignment_scores, alpha) + return -np.sum(labels - logit_scores) + + def alpha_fsec(alpha): + logit_scores = logit_partial_scores(alignment_scores, alpha) + return np.sum(logit_scores*(1 - logit_scores)) + + new_alpha = minimize(alpha_target, + updated_parameters['alpha'], + jac=alpha_fprime, + # hess=alpha_fsec, + # method='Newton-CG' + )['x'][0] + + logit_scores = logit_partial_scores(alignment_scores, new_alpha) + if verbose: + new_logL = logit_logL(logit_scores, labels) + print("Updated alpha:", new_alpha) + print('Updated logL:', new_logL) + + updated_parameters['alpha'] = new_alpha + + # Make a subgradient step + subgradient = logit_subgradient(alnlist, logit_scores, + labels, new_alpha, + alphabet) + if subgradient_scale != 1.0: + subgradient['Gap opens'] *= subgradient_scale + subgradient['Gap extends'] *= subgradient_scale + subgradient['Substitutions'] *= subgradient_scale + + stepsize = stepfunction(iternb) + if verbose: + print('New subgradient:') + print(subgradient) + print('Stepsize:', stepsize) + + subgradient_square_norm = 0 + if gap_mode == 'affine': + updated_parameters['open_gap_score'] += stepsize*subgradient['Gap opens'] + add_noise(1, iternb) + updated_parameters['extend_gap_score'] += stepsize*subgradient['Gap extends'] + add_noise(1, iternb) + subgradient_square_norm += subgradient['Gap opens']**2 + subgradient_square_norm += subgradient['Gap extends']**2 + if verbose: + print('Gap open step:', stepsize*subgradient['Gap opens']) + print('Gap extend step:', stepsize*subgradient['Gap extends']) + elif gap_mode == 'linear': + gapnb = subgradient['Gap opens'] + subgradient['Gap extends'] + updated_parameters['gap_score'] += stepsize*gapnb + add_noise(1, iternb) + subgradient_square_norm += gapnb**2 + if verbose: + print('Linear gap step:', stepsize*gapnb) + + if substitution_mode == 'simple': + match_subgradient = np.sum(np.diag(subgradient['Substitutions'])) + mismatch_subgradient = np.sum(subgradient['Substitutions']) - match_subgradient + updated_parameters['match_score'] += stepsize*match_subgradient + add_noise(1, iternb) + updated_parameters['mismatch_score'] += stepsize*mismatch_subgradient + add_noise(1, iternb) + subgradient_square_norm += match_subgradient**2 + subgradient_square_norm += mismatch_subgradient**2 + if verbose: + print('Match step:', stepsize*match_subgradient) + print('Mismatch step:', stepsize*mismatch_subgradient) + elif substitution_mode == 'symmetric': + subsM = subgradient['Substitutions'] + subsM = subsM + add_noise(subsM.shape, iternb) + # Summing mismatch counts to keep the matrix symmetric + # with off-diagonal elements corresponding to mismatches, + # subtracting the diagonal to avoid counting matches twice + subsM = subsM + subsM.T - np.diag(np.diag(subsM)) + # subsM = subsM + subsM.T + updated_parameters['substitution_matrix'] += stepsize*subsM + subgradient_square_norm += np.sum(subsM**2) + else: + subsM = subgradient['Substitutions'] + subsM = subsM + add_noise(subsM.shape, iternb) + updated_parameters['substitution_matrix'] += stepsize*subsM + subgradient_square_norm += np.sum(subsM**2) + subgradient_l2_trajectory.append(np.sqrt(subgradient_square_norm)) + if verbose: + print('New parameters:') + print(updated_parameters) + print('Subgradient norm:', subgradient_l2_trajectory[-1]) + if verbose: + print('End of iteration', iternb) + print() + + # Set final parameters + results = {} + if gap_mode == 'affine': + aligner.open_gap_score = updated_parameters['open_gap_score'] + aligner.extend_gap_score = updated_parameters['extend_gap_score'] + results['open_gap_score'] = updated_parameters['open_gap_score'] + results['extend_gap_score'] = updated_parameters['extend_gap_score'] + elif gap_mode == 'linear': + aligner.gap_score = updated_parameters['gap_score'] + results['gap_score'] = updated_parameters['gap_score'] + + if substitution_mode == 'simple': + aligner.match_score = updated_parameters['match_score'] + aligner.mismatch_score = updated_parameters['mismatch_score'] + results['match_score'] = updated_parameters['match_score'] + results['mismatch_score'] = updated_parameters['mismatch_score'] + else: + aligner.substitution_matrix = updated_parameters['substitution_matrix'] + results['substitution_matrix'] = updated_parameters['substitution_matrix'] + + # Realign with the new parameters + alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) + alignment_scores = [aln.score for aln in alnlist] + logit_scores = logit_partial_scores(alignment_scores, + updated_parameters['alpha']) + new_logL = logit_logL(logit_scores, labels) +## EL = 0 +## VL = 0 +## for ls in logit_scores: +## if 1e-30 < ls < 1-1e-30: +## EL += ls*np.log(ls) + (1-ls)*np.log(1-ls) +## VL += ls*(1-ls)*(np.log(ls)**2 + np.log(1-ls)**2) +## SDL = np.sqrt(VL) +## loglik_expectation.append(EL) +## loglik_sd.append(SDL) + loglik_trajectory.append(new_logL) + results['loglik_trajectory'] = loglik_trajectory + results['subgradient_l2_trajectory'] = subgradient_l2_trajectory + results['final_loglik'] = new_logL + results['aligner'] = aligner + if return_alignments: + results['alignments'] = alnlist + results['alignment_logit_scores'] = logit_scores + results['alpha'] = updated_parameters['alpha'] + # results['loglik_expectation_trajectory'] = loglik_expectation + # results['loglik_sd_trajectory'] = loglik_sd + return results + diff --git a/src/logit_link.py b/src/logit_link.py index 773226b..a366a08 100644 --- a/src/logit_link.py +++ b/src/logit_link.py @@ -88,6 +88,9 @@ def logit_subgradient(alignment_list, logit_scores, subgradient['Substitutions'][char1, char2] += weight return subgradient +def logit_subgradient_nwgrad(): + pass + if __name__ == '__main__': seq1 = 'CCTTTCCCGGGGTCTAAGGGTT' seq2 = 'TTACCCAAAATCTGGGCC' From ba1b0895f6fa8296600b11107d75cdfd94038acc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 08:51:23 +0200 Subject: [PATCH 02/65] Drop Python <3.13 upper bound; add tests/ package --- README.md | 2 +- pyproject.toml | 2 +- tests/__init__.py | 0 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py diff --git a/README.md b/README.md index b6305c8..c783299 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ case_study_for_mirna/ miRNA case study, trained models, and evaluation i ## Requirements -- Python `>=3.10,<3.13` +- Python `>=3.10` - `uv` for environment management - JupyterLab or VS Code notebook support for running `Simulation experiments.ipynb` diff --git a/pyproject.toml b/pyproject.toml index 7f32b0f..82b6c55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "discrimalign" version = "0.1.0" description = "Discriminative alignment parameter learning from labelled biological sequence pairs" readme = "README.md" -requires-python = ">=3.10,<3.13" +requires-python = ">=3.10" dependencies = [ "biopython>=1.86", "ipykernel>=6.29.0", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From a9af391eb0cbfdddc717017a01c069a8f9ad2596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 09:20:32 +0200 Subject: [PATCH 03/65] Add pytest config, test helpers and logit_link unit tests test_subgradient_alternating_gaps_are_all_opens is a strict xfail: a gap in one sequence does not reset the other's in-gap flag, so gap-A, gap-B, gap-A is counted as 2 opens + 1 extend instead of 3 opens. --- pyproject.toml | 14 +- tests/helpers.py | 179 +++++++++++++++++++++++++ tests/test_logit_link.py | 276 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 tests/helpers.py create mode 100644 tests/test_logit_link.py diff --git a/pyproject.toml b/pyproject.toml index 82b6c55..c96b774 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,4 +19,16 @@ dependencies = [ ] [tool.uv] -package = false \ No newline at end of file +package = false + +[dependency-groups] +dev = [ + "pytest>=8", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +markers = [ + "slow: end-to-end runs on larger simulated data (deselect with -m 'not slow')", +] diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..14e93bf --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,179 @@ +""" +Shared helpers for the test suite: random sequence data, aligners built from +DiscrimAlign parameter dicts, and an independent reference for the +log-likelihood as a function of the alignment parameters. +""" +import numpy as np +from Bio.Align import PairwiseAligner, substitution_matrices +from scipy.special import expit + +from src.logit_link import logit_logL, logit_subgradient +from src.optimization import get_first_alignment + +DNA = "ACGT" + +MODES = ["local", "global"] +GAP_MODES = ["affine", "linear"] +SUBSTITUTION_MODES = ["simple", "symmetric", "general"] + + +def random_seq(rng, length, alphabet=DNA): + return "".join(rng.choice(list(alphabet), size=length)) + + +def mutate(rng, seq, sub_rate=0.1, indel_rate=0.05, alphabet=DNA): + """Point substitutions plus single-residue insertions and deletions.""" + out = [] + for char in seq: + u = rng.random() + if u < indel_rate / 2: + continue + if u < indel_rate: + out.append(rng.choice(list(alphabet))) + if rng.random() < sub_rate: + char = rng.choice([c for c in alphabet if c != char]) + out.append(char) + if not out: + out.append(seq[0]) + return "".join(out) + + +def make_pairs(rng, n_pos, n_neg, length, alphabet=DNA, + sub_rate=0.1, indel_rate=0.05): + """Positives are mutated copies, negatives are unrelated random sequences.""" + seqsA, seqsB, labels = [], [], [] + for _ in range(n_pos): + seq = random_seq(rng, length, alphabet) + seqsA.append(seq) + seqsB.append(mutate(rng, seq, sub_rate, indel_rate, alphabet)) + labels.append(1) + for _ in range(n_neg): + seqsA.append(random_seq(rng, length, alphabet)) + seqsB.append(random_seq(rng, length, alphabet)) + labels.append(0) + order = rng.permutation(len(labels)) + return ([seqsA[i] for i in order], + [seqsB[i] for i in order], + np.array([labels[i] for i in order])) + + +def random_params(rng, gap_mode, substitution_mode, alphabet=DNA, alpha=None): + """ + Random, non-integer parameters in DiscrimAlign's dict format. + + Continuous values make ties between alignments with different count + vectors a measure-zero event, so the log-likelihood is differentiable at + these points. The extend score is always above the open score. + """ + params = {"alpha": float(rng.normal(-1.0, 0.3)) if alpha is None else alpha} + if gap_mode == "affine": + params["open_gap_score"] = float(rng.uniform(-4.0, -2.5)) + params["extend_gap_score"] = float(rng.uniform(-1.5, -0.3)) + else: + params["gap_score"] = float(rng.uniform(-3.0, -1.0)) + if substitution_mode == "simple": + params["match_score"] = float(rng.uniform(1.5, 3.0)) + params["mismatch_score"] = float(rng.uniform(-2.0, -0.5)) + else: + n = len(alphabet) + data = rng.uniform(-2.0, -0.5, size=(n, n)) + data[np.diag_indices(n)] = rng.uniform(1.5, 3.0, size=n) + if substitution_mode == "symmetric": + data = (data + data.T) / 2 + params["substitution_matrix"] = substitution_matrices.Array( + alphabet=alphabet, data=data) + return params + + +def make_aligner(mode, params): + """A PairwiseAligner configured from a DiscrimAlign parameter dict.""" + aligner = PairwiseAligner() + aligner.mode = mode + if "gap_score" in params: + aligner.gap_score = params["gap_score"] + else: + aligner.open_gap_score = params["open_gap_score"] + aligner.extend_gap_score = params["extend_gap_score"] + if "substitution_matrix" in params: + aligner.substitution_matrix = params["substitution_matrix"] + else: + aligner.match_score = params["match_score"] + aligner.mismatch_score = params["mismatch_score"] + return aligner + + +def align_all(seqsA, seqsB, aligner): + return [get_first_alignment(a, b, aligner) for a, b in zip(seqsA, seqsB)] + + +def scores_at(seqsA, seqsB, mode, params): + aligner = make_aligner(mode, params) + return np.array([aln.score for aln in align_all(seqsA, seqsB, aligner)]) + + +def loglik_at(seqsA, seqsB, labels, mode, params): + """Log-likelihood at params, with alpha held fixed at params['alpha'].""" + scores = scores_at(seqsA, seqsB, mode, params) + return logit_logL(expit(params["alpha"] + scores), labels) + + +def optimal_alpha(scores, labels, alpha0=0.0, iters=100): + """Newton's method on the intercept, independent of scipy.minimize.""" + scores = np.asarray(scores, dtype=float) + labels = np.asarray(labels, dtype=float) + alpha = alpha0 + for _ in range(iters): + p = expit(alpha + scores) + g = np.sum(labels - p) + h = -np.sum(p * (1 - p)) + step = g / h + alpha -= step + if abs(step) < 1e-14: + break + return alpha + + +def model_gradient(subgradient, gap_mode, substitution_mode): + """ + Map logit_subgradient's raw counts onto the model's free parameters, + in DiscrimAlign's dict format (without alpha). + """ + grad = {} + if gap_mode == "affine": + grad["open_gap_score"] = subgradient["Gap opens"] + grad["extend_gap_score"] = subgradient["Gap extends"] + else: + grad["gap_score"] = subgradient["Gap opens"] + subgradient["Gap extends"] + G = np.asarray(subgradient["Substitutions"]) + if substitution_mode == "simple": + grad["match_score"] = np.trace(G) + grad["mismatch_score"] = G.sum() - np.trace(G) + elif substitution_mode == "symmetric": + grad["substitution_matrix"] = G + G.T - np.diag(np.diag(G)) + else: + grad["substitution_matrix"] = G + return grad + + +def subgradient_at(seqsA, seqsB, labels, mode, params, alphabet): + aligner = make_aligner(mode, params) + alns = align_all(seqsA, seqsB, aligner) + scores = np.array([aln.score for aln in alns]) + logit_scores = expit(params["alpha"] + scores) + return logit_subgradient(alns, logit_scores, labels, params["alpha"], alphabet) + + +def perturbed(params, key, delta, index=None, symmetric=False): + """Copy of params with one scalar (or one matrix entry) shifted by delta.""" + new = dict(params) + if index is None: + new[key] = params[key] + delta + return new + M = substitution_matrices.Array(alphabet=params[key].alphabet, + data=np.array(params[key])) + i, j = index + M[i, j] += delta + if symmetric and i != j: + M[j, i] += delta + new[key] = M + return new diff --git a/tests/test_logit_link.py b/tests/test_logit_link.py new file mode 100644 index 0000000..efa6220 --- /dev/null +++ b/tests/test_logit_link.py @@ -0,0 +1,276 @@ +"""Unit tests for src.logit_link.""" +import numpy as np +import pytest +from Bio.Align import PairwiseAligner + +from src.logit_link import (d2lda2, dlda, logit_logL, logit_partial_scores, + logit_subgradient) +from src.optimization import EmptyLocalAlignment + + +# --- logit_partial_scores --------------------------------------------------- + +def test_partial_scores_is_sigmoid_of_shifted_scores(): + scores = [-3.0, -0.5, 0.0, 1.25, 4.0] + alpha = 0.7 + expected = [1.0 / (1.0 + np.exp(-(alpha + s))) for s in scores] + np.testing.assert_allclose(logit_partial_scores(scores, alpha), expected, rtol=1e-14) + + +def test_partial_scores_zero_gives_half(): + np.testing.assert_allclose(logit_partial_scores([0.0, -2.0], [0.0, 2.0]), [0.5, 0.5]) + + +def test_partial_scores_accepts_scalar_and_returns_array(): + out = logit_partial_scores(1.0, 0.0) + assert isinstance(out, (np.ndarray, np.floating)) + assert out == pytest.approx(1.0 / (1.0 + np.exp(-1.0))) + + +def test_partial_scores_accepts_integer_scores(): + out = logit_partial_scores([0, 1, 2], 0) + assert out.dtype == float + + +def test_partial_scores_extreme_values_saturate_without_nan(): + out = logit_partial_scores([-1e4, 1e4], 0.0) + assert np.all(np.isfinite(out)) + assert out[0] == 0.0 + assert out[1] == 1.0 + + +def test_partial_scores_monotone_in_score(): + scores = np.linspace(-10, 10, 41) + out = logit_partial_scores(scores, -1.3) + assert np.all(np.diff(out) > 0) + + +def test_partial_scores_empty_input(): + assert logit_partial_scores([], 0.0).shape == (0,) + + +# --- logit_logL ------------------------------------------------------------- + +def test_logL_matches_bernoulli_formula(): + p = np.array([0.1, 0.4, 0.8, 0.95]) + y = np.array([0, 1, 1, 0]) + expected = np.sum(y * np.log(p) + (1 - y) * np.log(1 - p)) + assert logit_logL(p, y) == pytest.approx(expected, rel=1e-14) + + +def test_logL_returns_python_float(): + assert type(logit_logL([0.3], [1])) is float + + +def test_logL_is_nonpositive(): + rng = np.random.default_rng(0) + p = rng.uniform(0.01, 0.99, size=50) + y = rng.integers(0, 2, size=50) + assert logit_logL(p, y) <= 0.0 + + +def test_logL_perfect_prediction_is_near_zero(): + assert logit_logL([1.0, 0.0], [1, 0]) == pytest.approx(0.0, abs=1e-12) + + +def test_logL_clips_certain_wrong_predictions_to_finite_value(): + value = logit_logL([0.0, 1.0], [1, 0]) + assert np.isfinite(value) + assert value < -60 # 2 * log(eps) is about -72 + + +def test_logL_accepts_lists(): + assert logit_logL([0.25, 0.75], [0, 1]) == pytest.approx(2 * np.log(0.75)) + + +def test_logL_accepts_boolean_labels(): + assert logit_logL([0.25, 0.75], np.array([False, True])) == pytest.approx(2 * np.log(0.75)) + + +@pytest.mark.parametrize("labels", [[0, 2], [-1, 1], [0.5, 1]]) +def test_logL_rejects_non_binary_labels(labels): + with pytest.raises(ValueError): + logit_logL([0.3, 0.6], labels) + + +def test_logL_empty_is_zero(): + assert logit_logL([], []) == 0.0 + + +# --- dlda and d2lda2 -------------------------------------------------------- + +def _logL_of_alpha(alpha, scores, labels): + return logit_logL(logit_partial_scores(scores, alpha), labels) + + +@pytest.mark.parametrize("seed", range(5)) +def test_dlda_matches_finite_difference(seed): + rng = np.random.default_rng(seed) + scores = rng.normal(0, 3, size=20) + labels = rng.integers(0, 2, size=20) + alpha, h = rng.normal(), 1e-6 + numeric = (_logL_of_alpha(alpha + h, scores, labels) + - _logL_of_alpha(alpha - h, scores, labels)) / (2 * h) + analytic = dlda(logit_partial_scores(scores, alpha), labels) + assert analytic == pytest.approx(numeric, rel=1e-6, abs=1e-8) + + +@pytest.mark.parametrize("seed", range(5)) +def test_d2lda2_matches_finite_difference(seed): + rng = np.random.default_rng(seed) + scores = rng.normal(0, 3, size=20) + labels = rng.integers(0, 2, size=20) + alpha, h = rng.normal(), 1e-5 + d = lambda a: dlda(logit_partial_scores(scores, a), labels) + numeric = (d(alpha + h) - d(alpha - h)) / (2 * h) + analytic = d2lda2(logit_partial_scores(scores, alpha), labels) + assert analytic == pytest.approx(numeric, rel=1e-6) + + +def test_d2lda2_is_negative(): + p = np.array([0.2, 0.5, 0.9]) + assert d2lda2(p, [0, 1, 1]) < 0 + + +def test_dlda_vanishes_when_mean_prediction_matches_mean_label(): + assert dlda(np.array([0.25, 0.75]), np.array([0, 1])) == pytest.approx(0.0) + + +# --- logit_subgradient on hand-built alignments ----------------------------- +# +# logit_subgradient only indexes aln[0] and aln[1], so a pair of gapped +# strings stands in for an Alignment object. + +def _single(aln, alphabet="ACGT", weight=1.0): + """Subgradient of one alignment with label - logit_score == weight.""" + return logit_subgradient([aln], [0.0], [weight], 0.0, alphabet) + + +def test_subgradient_counts_matches_and_mismatches(): + sg = _single(("ACGT", "ACCT")) + G = np.asarray(sg["Substitutions"]) + assert G.sum() == 4 + assert sg["Substitutions"]["A", "A"] == 1 + assert sg["Substitutions"]["C", "C"] == 1 + assert sg["Substitutions"]["G", "C"] == 1 + assert sg["Substitutions"]["C", "G"] == 0 # direction matters + assert sg["Substitutions"]["T", "T"] == 1 + assert sg["Gap opens"] == 0 + assert sg["Gap extends"] == 0 + + +def test_subgradient_matrix_carries_alphabet_order(): + sg = _single(("AC", "AC"), alphabet="TGCA") + assert "".join(sg["Substitutions"].alphabet) == "TGCA" + assert np.asarray(sg["Substitutions"])[3, 3] == 1 # A is last + + +@pytest.mark.parametrize("aln, opens, extends", [ + (("A-C", "AGC"), 1, 0), + (("A--C", "AGGC"), 1, 1), + (("A---C", "AGGGC"), 1, 2), + (("AGC", "A-C"), 1, 0), + (("AGGGC", "A---C"), 1, 2), + (("-AC", "GAC"), 1, 0), # leading gap + (("AC--", "ACGT"), 1, 1), # trailing gap + (("A-C-G", "ATCTG"), 2, 0), # two gaps separated by a match + (("A-CG", "AT-G"), 2, 0), # gap in A, then gap in B + (("A--C--", "AGTCGT"), 2, 2), + (("--", "AC"), 1, 1), # all gaps +]) +def test_subgradient_gap_counts(aln, opens, extends): + sg = _single(aln) + assert sg["Gap opens"] == opens + assert sg["Gap extends"] == extends + + +@pytest.mark.xfail(strict=True, reason=( + "BUG: a gap in one sequence does not reset the other sequence's in-gap " + "flag, so gap-in-A, gap-in-B, gap-in-A is counted as 2 opens + 1 extend " + "instead of 3 opens. Biopython scores it as 3 opens.")) +def test_subgradient_alternating_gaps_are_all_opens(): + sg = _single(("-A-", "C-G")) + assert sg["Gap opens"] == 3 + assert sg["Gap extends"] == 0 + + +def test_subgradient_gap_after_match_reopens(): + sg = _single(("A-CC-A", "AGCCTA")) + assert sg["Gap opens"] == 2 + assert sg["Gap extends"] == 0 + + +def test_subgradient_weight_is_label_minus_logit(): + sg = logit_subgradient([("AC", "AC")], [0.3], [1], 0.0, "ACGT") + assert sg["Substitutions"]["A", "A"] == pytest.approx(0.7) + sg = logit_subgradient([("AC", "AC")], [0.3], [0], 0.0, "ACGT") + assert sg["Substitutions"]["A", "A"] == pytest.approx(-0.3) + + +def test_subgradient_ignores_alpha_argument(): + a = logit_subgradient([("A-C", "AGC")], [0.4], [1], -5.0, "ACGT") + b = logit_subgradient([("A-C", "AGC")], [0.4], [1], 7.0, "ACGT") + np.testing.assert_array_equal(np.asarray(a["Substitutions"]), np.asarray(b["Substitutions"])) + assert a["Gap opens"] == b["Gap opens"] + + +def test_subgradient_sums_over_alignments_linearly(): + alns = [("A-C", "AGC"), ("GGT", "GAT"), ("AC--", "ACGT")] + logits = [0.2, 0.9, 0.5] + labels = [1, 0, 1] + total = logit_subgradient(alns, logits, labels, 0.0, "ACGT") + parts = [logit_subgradient([a], [p], [y], 0.0, "ACGT") + for a, p, y in zip(alns, logits, labels)] + np.testing.assert_allclose(np.asarray(total["Substitutions"]), + sum(np.asarray(p["Substitutions"]) for p in parts)) + assert total["Gap opens"] == pytest.approx(sum(p["Gap opens"] for p in parts)) + assert total["Gap extends"] == pytest.approx(sum(p["Gap extends"] for p in parts)) + + +def test_subgradient_zero_when_predictions_are_exact(): + sg = logit_subgradient([("ACG", "A-G"), ("TT", "TA")], [1.0, 0.0], [1, 0], 0.0, "ACGT") + assert np.all(np.asarray(sg["Substitutions"]) == 0) + assert sg["Gap opens"] == 0 + assert sg["Gap extends"] == 0 + + +def test_subgradient_of_empty_local_alignment_is_zero(): + sg = logit_subgradient([EmptyLocalAlignment()], [0.2], [1], 0.0, "ACGT") + assert np.all(np.asarray(sg["Substitutions"]) == 0) + assert sg["Gap opens"] == 0 + assert sg["Gap extends"] == 0 + + +def test_subgradient_empty_list(): + sg = logit_subgradient([], [], [], 0.0, "AC") + assert np.asarray(sg["Substitutions"]).shape == (2, 2) + assert sg["Gap opens"] == 0 + + +def test_subgradient_rejects_length_mismatch(): + with pytest.raises(AssertionError): + logit_subgradient([("A", "A")], [0.1, 0.2], [1, 0], 0.0, "ACGT") + with pytest.raises(AssertionError): + logit_subgradient([("A", "A")], [0.1], [1, 0], 0.0, "ACGT") + + +def test_subgradient_rejects_character_outside_alphabet(): + with pytest.raises((KeyError, IndexError, ValueError)): + _single(("AX", "AA"), alphabet="ACGT") + + +def test_subgradient_on_biopython_alignment(): + aligner = PairwiseAligner() + aligner.mode = "global" + aligner.match_score = 2 + aligner.mismatch_score = -1 + aligner.open_gap_score = -3 + aligner.extend_gap_score = -1 + aln = next(aligner.align("ACGTTGCA", "ACGGCA")) + sg = _single(aln) + G = np.asarray(sg["Substitutions"]) + counts = aln.counts() + assert np.trace(G) == counts.identities + assert G.sum() - np.trace(G) == counts.mismatches + assert sg["Gap opens"] == counts.open_gaps + assert sg["Gap extends"] == counts.extend_gaps From a51a28c5d9443398cccd606b49dcb70ce60bdb25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 09:21:38 +0200 Subject: [PATCH 04/65] Add unit tests for src.optimization Initial estimators are checked on fake alignments with prescribed count features drawn from a known logistic model, which pins down the mapping from regression coefficients to parameter names and matrix orientation. --- tests/test_optimization.py | 326 +++++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 tests/test_optimization.py diff --git a/tests/test_optimization.py b/tests/test_optimization.py new file mode 100644 index 0000000..3c1f7c5 --- /dev/null +++ b/tests/test_optimization.py @@ -0,0 +1,326 @@ +"""Unit tests for src.optimization.""" +import warnings + +import numpy as np +import pytest +from Bio.Align import PairwiseAligner +from joblib import Parallel +from scipy.special import expit + +from src.optimization import (EmptyAlignmentCounts, EmptyLocalAlignment, + create_alignment_workers, create_constant_step, + create_powerstep, get_first_alignment, + get_initial_estimate) +from tests.helpers import DNA, align_all, make_aligner, make_pairs, random_params + + +# --- EmptyLocalAlignment ---------------------------------------------------- + +def test_empty_local_alignment_score_is_zero(): + assert EmptyLocalAlignment().score == 0.0 + + +def test_empty_local_alignment_counts_are_zero(): + counts = EmptyLocalAlignment().counts() + for field in ("identities", "mismatches", "open_gaps", "extend_gaps", "gaps"): + assert getattr(counts, field) == 0 + + +def test_empty_local_alignment_rows_are_empty_strings(): + aln = EmptyLocalAlignment() + assert aln[0] == "" + assert aln[1] == "" + + +@pytest.mark.parametrize("index", [2, -1, 5]) +def test_empty_local_alignment_other_rows_raise(index): + with pytest.raises(IndexError): + EmptyLocalAlignment()[index] + + +def test_empty_alignment_counts_class_attributes(): + assert EmptyAlignmentCounts.gaps == 0 + + +# --- get_first_alignment ---------------------------------------------------- + +def _simple_aligner(mode): + aligner = PairwiseAligner() + aligner.mode = mode + aligner.match_score = 5 + aligner.mismatch_score = -4 + aligner.open_gap_score = -8 + aligner.extend_gap_score = -0.5 + return aligner + + +def test_first_alignment_global(): + aln = get_first_alignment("ACGT", "ACGT", _simple_aligner("global")) + assert aln.score == 20 + assert aln[0] == "ACGT" + + +def test_first_alignment_local_substring(): + aln = get_first_alignment("TTTACGTTTT", "GGACGGG", _simple_aligner("local")) + assert aln.score == 15 # ACG + + +@pytest.mark.parametrize("a, b", [("AAAA", "CCCC"), ("A", "C"), ("ACAC", "GTGT")]) +def test_first_alignment_local_without_positive_score_is_empty(a, b): + aln = get_first_alignment(a, b, _simple_aligner("local")) + assert isinstance(aln, EmptyLocalAlignment) + assert aln.score == 0.0 + + +def test_first_alignment_global_always_exists_for_unrelated_sequences(): + aln = get_first_alignment("AAAA", "CCCC", _simple_aligner("global")) + assert aln.score < 0 + + +def test_first_alignment_reraises_stopiteration_outside_local_mode(): + class NoAlignments: + mode = "global" + + def align(self, a, b): + return iter(()) + + with pytest.raises(StopIteration): + get_first_alignment("A", "C", NoAlignments()) + + +# --- create_alignment_workers ----------------------------------------------- + +@pytest.mark.parametrize("mode", ["local", "global"]) +def test_alignment_workers_reproduce_serial_alignments(mode): + rng = np.random.default_rng(1) + seqsA, seqsB, _ = make_pairs(rng, 4, 4, 12) + aligner = _simple_aligner(mode) + parallel = Parallel(n_jobs=2, prefer="threads") + alns = parallel(create_alignment_workers(seqsA, seqsB, aligner)) + expected = align_all(seqsA, seqsB, aligner) + assert [a.score for a in alns] == [e.score for e in expected] + + +def test_alignment_workers_is_lazy_and_sized_by_input(): + gen = create_alignment_workers(["A", "C", "G"], ["A", "C", "G"], _simple_aligner("global")) + assert len(list(gen)) == 3 + + +# --- step functions --------------------------------------------------------- + +@pytest.mark.parametrize("scale", [0.0, 0.01, 3.5]) +def test_constant_step(scale): + step = create_constant_step(scale) + assert [step(i) for i in range(5)] == [scale] * 5 + + +def test_powerstep_default_is_inverse_square_root(): + step = create_powerstep(2.0) + for i in range(10): + assert step(i) == pytest.approx(2.0 / np.sqrt(i + 1)) + + +@pytest.mark.parametrize("power", [0.0, 0.5, 1.0, 2.0]) +def test_powerstep_power(power): + step = create_powerstep(1.0, power=power) + assert step(3) == pytest.approx(4.0 ** -power) + + +def test_powerstep_burnin_is_constant_then_decays_from_scale(): + step = create_powerstep(1.0, power=1.0, burnin=3) + assert [step(i) for i in range(3)] == [1.0, 1.0, 1.0] + assert step(3) == pytest.approx(1.0) # first post-burnin step is still the full scale + assert step(4) == pytest.approx(0.5) + assert step(12) == pytest.approx(0.1) + + +def test_powerstep_is_nonincreasing(): + step = create_powerstep(0.3, power=0.7, burnin=2) + values = [step(i) for i in range(50)] + assert all(a >= b for a, b in zip(values, values[1:])) + + +# --- initial estimate on synthetic count features --------------------------- +# +# Fake alignments with prescribed count features, labelled by a known logistic +# model. Fitting recovers the model, which pins down the mapping from +# regression coefficients to parameter names. + +class FakeCounts: + def __init__(self, identities, mismatches, open_gaps, extend_gaps): + self.identities = identities + self.mismatches = mismatches + self.open_gaps = open_gaps + self.extend_gaps = extend_gaps + self.gaps = open_gaps + extend_gaps + + +class FakeAlignment: + def __init__(self, row0, row1, counts): + self.rows = (row0, row1) + self._counts = counts + + def counts(self): + return self._counts + + def __getitem__(self, index): + return self.rows[index] + + +def _fake_simple_data(rng, n, coefs, alpha, linear=False): + alns, labels = [], [] + for _ in range(n): + ident, mism = rng.poisson(4), rng.poisson(3) + opens, extends = rng.poisson(1.0), rng.poisson(1.5) + if linear: + x = np.array([ident, mism, opens + extends]) + else: + x = np.array([ident, mism, opens, extends]) + labels.append(int(rng.random() < expit(alpha + x @ coefs))) + alns.append(FakeAlignment("", "", FakeCounts(ident, mism, opens, extends))) + return alns, np.array(labels) + + +def test_initial_estimate_simple_affine_recovers_coefficients(): + rng = np.random.default_rng(0) + coefs = np.array([0.8, -0.6, -1.0, -0.3]) + alns, labels = _fake_simple_data(rng, 4000, coefs, alpha=-0.5) + est = get_initial_estimate(alns, labels, "simple", "affine") + assert set(est) == {"alpha", "match_score", "mismatch_score", + "open_gap_score", "extend_gap_score"} + assert est["alpha"] == pytest.approx(-0.5, abs=0.25) + assert est["match_score"] == pytest.approx(0.8, abs=0.1) + assert est["mismatch_score"] == pytest.approx(-0.6, abs=0.1) + assert est["open_gap_score"] == pytest.approx(-1.0, abs=0.15) + assert est["extend_gap_score"] == pytest.approx(-0.3, abs=0.1) + + +def test_initial_estimate_simple_linear_recovers_coefficients(): + rng = np.random.default_rng(1) + coefs = np.array([0.8, -0.6, -0.7]) + alns, labels = _fake_simple_data(rng, 4000, coefs, alpha=-0.5, linear=True) + est = get_initial_estimate(alns, labels, "simple", "linear") + assert set(est) == {"alpha", "match_score", "mismatch_score", "gap_score"} + assert est["match_score"] == pytest.approx(0.8, abs=0.1) + assert est["mismatch_score"] == pytest.approx(-0.6, abs=0.1) + assert est["gap_score"] == pytest.approx(-0.7, abs=0.1) + + +def _fake_full_data(rng, n, matrix, open_coef, extend_coef, alpha, alphabet): + alns, labels = [], [] + k = len(alphabet) + for _ in range(n): + pair_counts = rng.poisson(0.6, size=(k, k)) + opens, extends = rng.poisson(1.0), rng.poisson(1.5) + row0 = "".join(alphabet[i] * pair_counts[i, j] for i in range(k) for j in range(k)) + row1 = "".join(alphabet[j] * pair_counts[i, j] for i in range(k) for j in range(k)) + # Gap columns in the rows must be ignored by the substitution features. + row0 += "-" * opens + row1 += alphabet[0] * opens + eta = alpha + np.sum(pair_counts * matrix) + opens * open_coef + extends * extend_coef + labels.append(int(rng.random() < expit(eta))) + ident = int(np.trace(pair_counts)) + alns.append(FakeAlignment(row0, row1, + FakeCounts(ident, int(pair_counts.sum()) - ident, opens, extends))) + return alns, np.array(labels) + + +def test_initial_estimate_general_affine_matrix_orientation(): + rng = np.random.default_rng(2) + alphabet = "ACG" + matrix = np.full((3, 3), -0.3) + matrix[np.diag_indices(3)] = 0.6 + matrix[0, 1] = 1.2 # A->C strongly positive, C->A not + alns, labels = _fake_full_data(rng, 4000, matrix, -0.8, -0.2, -0.3, alphabet) + est = get_initial_estimate(alns, labels, "general", "affine", alphabet) + assert set(est) == {"alpha", "substitution_matrix", "open_gap_score", "extend_gap_score"} + M = est["substitution_matrix"] + assert "".join(M.alphabet) == alphabet + # Default L2 penalty shrinks the fit a little, so the tolerance is loose. + np.testing.assert_allclose(np.asarray(M), matrix, atol=0.2) + assert M["A", "C"] > M["C", "A"] + 0.8 + assert est["open_gap_score"] == pytest.approx(-0.8, abs=0.2) + assert est["extend_gap_score"] == pytest.approx(-0.2, abs=0.15) + + +def test_initial_estimate_symmetric_is_symmetrized_general(): + rng = np.random.default_rng(3) + alphabet = "ACG" + matrix = np.full((3, 3), -0.3) + matrix[np.diag_indices(3)] = 0.6 + matrix[0, 1] = 1.2 + alns, labels = _fake_full_data(rng, 1000, matrix, -0.8, -0.2, -0.3, alphabet) + general = get_initial_estimate(alns, labels, "general", "affine", alphabet) + symmetric = get_initial_estimate(alns, labels, "symmetric", "affine", alphabet) + G = np.asarray(general["substitution_matrix"]) + S = symmetric["substitution_matrix"] + np.testing.assert_allclose(np.asarray(S), (G + G.T) / 2) + assert "".join(S.alphabet) == alphabet + assert symmetric["open_gap_score"] == pytest.approx(general["open_gap_score"]) + + +@pytest.mark.parametrize("substitution_mode", ["general", "symmetric"]) +def test_initial_estimate_full_linear_merges_gap_coefficients(substitution_mode): + rng = np.random.default_rng(4) + alns, labels = _fake_full_data(rng, 500, np.eye(3) - 0.3, -0.8, -0.2, -0.3, "ACG") + affine = get_initial_estimate(alns, labels, substitution_mode, "affine", "ACG") + linear = get_initial_estimate(alns, labels, substitution_mode, "linear", "ACG") + assert set(linear) == {"alpha", "substitution_matrix", "gap_score"} + assert linear["gap_score"] == pytest.approx( + affine["open_gap_score"] + affine["extend_gap_score"]) + + +def test_initial_estimate_rejects_unknown_modes(): + alns = [FakeAlignment("", "", FakeCounts(1, 0, 0, 0))] * 2 + with pytest.raises(AssertionError): + get_initial_estimate(alns, [0, 1], "simple", "convex") + with pytest.raises(AssertionError): + get_initial_estimate(alns, [0, 1], "banded", "affine") + + +@pytest.mark.parametrize("substitution_mode", ["general", "symmetric"]) +def test_initial_estimate_full_requires_alphabet(substitution_mode): + alns = [FakeAlignment("", "", FakeCounts(1, 0, 0, 0))] * 2 + with pytest.raises(AssertionError): + get_initial_estimate(alns, [0, 1], substitution_mode, "affine") + + +def test_initial_estimate_needs_both_classes(): + alns = [FakeAlignment("", "", FakeCounts(i, 1, 0, 0)) for i in range(4)] + with pytest.raises(ValueError): + get_initial_estimate(alns, [1, 1, 1, 1], "simple", "affine") + + +# --- initial estimate on real alignments ------------------------------------ + +@pytest.mark.parametrize("mode", ["local", "global"]) +@pytest.mark.parametrize("gap_mode", ["affine", "linear"]) +@pytest.mark.parametrize("substitution_mode", ["simple", "symmetric", "general"]) +def test_initial_estimate_on_real_alignments(mode, gap_mode, substitution_mode): + rng = np.random.default_rng(5) + seqsA, seqsB, labels = make_pairs(rng, 20, 20, 25, sub_rate=0.2) + params = random_params(rng, gap_mode, substitution_mode) + alns = align_all(seqsA, seqsB, make_aligner(mode, params)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # convergence warnings on tiny data + est = get_initial_estimate(alns, labels, substitution_mode, gap_mode, DNA) + for key, value in est.items(): + assert np.all(np.isfinite(np.asarray(value))), key + if substitution_mode == "simple": + assert est["match_score"] > est["mismatch_score"] + else: + M = np.asarray(est["substitution_matrix"]) + assert np.mean(np.diag(M)) > np.mean(M[~np.eye(4, dtype=bool)]) + if substitution_mode == "symmetric": + np.testing.assert_allclose(M, M.T) + + +def test_initial_estimate_accepts_empty_local_alignments(): + alns = [EmptyLocalAlignment(), EmptyLocalAlignment(), + FakeAlignment("AC", "AC", FakeCounts(2, 0, 0, 0)), + FakeAlignment("AG", "AG", FakeCounts(2, 0, 0, 0))] + labels = [0, 0, 1, 1] + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + est = get_initial_estimate(alns, labels, "general", "affine", "ACGT") + assert np.all(np.isfinite(np.asarray(est["substitution_matrix"]))) From 7b16624d7a441042e3c041e7c2a01ce14ab846b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 09:24:03 +0200 Subject: [PATCH 05/65] Add alignment-helper tests and gradient-consistency integration tests Checks that every alignment score equals its count vector dotted with the parameters, and that the model gradient built from logit_subgradient matches central finite differences of the log-likelihood in all twelve mode combinations. The reference log-likelihood is computed from logits, since logit_logL loses precision on confident predictions. --- tests/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 146 bytes tests/__pycache__/helpers.cpython-314.pyc | Bin 0 -> 10230 bytes ...align_helpers.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 27575 bytes ...t_consistency.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 35116 bytes .../test_gradient_consistency.cpython-314.pyc | Bin 0 -> 15856 bytes ...st_logit_link.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 65759 bytes ..._optimization.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 62967 bytes tests/helpers.py | 15 +- tests/test_discrimalign_helpers.py | 117 ++++++++++ tests/test_gradient_consistency.py | 217 ++++++++++++++++++ 10 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 tests/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/__pycache__/helpers.cpython-314.pyc create mode 100644 tests/__pycache__/test_discrimalign_helpers.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_gradient_consistency.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_gradient_consistency.cpython-314.pyc create mode 100644 tests/__pycache__/test_logit_link.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_optimization.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/test_discrimalign_helpers.py create mode 100644 tests/test_gradient_consistency.py diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..012e2eff3473006bcf1fd09df64c023082e89594 GIT binary patch literal 146 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_gradient_consistency.cpython-314.pyc b/tests/__pycache__/test_gradient_consistency.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51374051be4a5a29576f33281cc42b689465a623 GIT binary patch literal 15856 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_logit_link.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_logit_link.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90af6884f6ce3d2de49fa8469f1a235962a6cd62 GIT binary patch literal 65759 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 literal 0 HcmV?d00001 diff --git a/tests/helpers.py b/tests/helpers.py index 14e93bf..6b726ab 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -7,7 +7,7 @@ from Bio.Align import PairwiseAligner, substitution_matrices from scipy.special import expit -from src.logit_link import logit_logL, logit_subgradient +from src.logit_link import logit_subgradient from src.optimization import get_first_alignment DNA = "ACGT" @@ -112,9 +112,16 @@ def scores_at(seqsA, seqsB, mode, params): def loglik_at(seqsA, seqsB, labels, mode, params): - """Log-likelihood at params, with alpha held fixed at params['alpha'].""" - scores = scores_at(seqsA, seqsB, mode, params) - return logit_logL(expit(params["alpha"] + scores), labels) + """ + Log-likelihood at params, with alpha held fixed at params['alpha']. + + Computed from the logits as sum(y*z - log(1 + e^z)), independently of + logit_logL, and without the cancellation in 1 - expit(z) that makes + logit_logL too noisy for finite differences on confident predictions. + """ + z = params["alpha"] + scores_at(seqsA, seqsB, mode, params) + labels = np.asarray(labels, dtype=float) + return float(np.sum(labels * z - np.logaddexp(0.0, z))) def optimal_alpha(scores, labels, alpha0=0.0, iters=100): diff --git a/tests/test_discrimalign_helpers.py b/tests/test_discrimalign_helpers.py new file mode 100644 index 0000000..fefece2 --- /dev/null +++ b/tests/test_discrimalign_helpers.py @@ -0,0 +1,117 @@ +"""Unit tests for the private helpers in src.discrimalign.""" +import numpy as np +import pytest +from Bio.Align import PairwiseAligner, substitution_matrices + +from src.discrimalign import (_align_pair_chunk, _align_pairs, _matrix_alphabet, + _pair_chunks, _warm_start_alphabet) +from src.optimization import EmptyLocalAlignment +from tests.helpers import make_aligner, make_pairs, random_params + + +# --- _pair_chunks ----------------------------------------------------------- + +@pytest.mark.parametrize("n, chunk_size", [(0, 3), (1, 1), (5, 1), (5, 2), (6, 3), (7, 10)]) +def test_pair_chunks_cover_input_in_order(n, chunk_size): + A = [f"a{i}" for i in range(n)] + B = [f"b{i}" for i in range(n)] + chunks = list(_pair_chunks(A, B, chunk_size)) + assert [p for chunk in chunks for p in chunk] == list(zip(A, B)) + assert all(1 <= len(c) <= chunk_size for c in chunks) + assert len(chunks) == -(-n // chunk_size) + + +# --- _align_pair_chunk / _align_pairs --------------------------------------- + +def _aligner(mode): + rng = np.random.default_rng(0) + return make_aligner(mode, random_params(rng, "affine", "simple")) + + +@pytest.mark.parametrize("mode", ["local", "global"]) +def test_align_pair_chunk_matches_direct_alignment(mode): + aligner = _aligner(mode) + pairs = [("ACGT", "ACT"), ("GGGA", "GGA"), ("T", "T")] + alns = _align_pair_chunk(pairs, aligner) + assert [a.score for a in alns] == pytest.approx( + [aligner.score(x, y) for x, y in pairs], rel=1e-12) + + +def test_align_pair_chunk_returns_empty_local_alignment(): + aligner = _aligner("local") + alns = _align_pair_chunk([("AAA", "CCC")], aligner) + assert isinstance(alns[0], EmptyLocalAlignment) + + +@pytest.mark.parametrize("num_threads", [1, 2, 3, 8, 64]) +@pytest.mark.parametrize("mode", ["local", "global"]) +def test_align_pairs_is_independent_of_thread_count(num_threads, mode): + rng = np.random.default_rng(num_threads) + seqsA, seqsB, _ = make_pairs(rng, 9, 8, 15) + aligner = _aligner(mode) + alns = _align_pairs(seqsA, seqsB, aligner, num_threads) + serial = _align_pairs(seqsA, seqsB, aligner, 1) + assert len(alns) == len(seqsA) + assert [a.score for a in alns] == [a.score for a in serial] + # aligner.score() sums in a different order than align(), so only approx. + assert [a.score for a in alns] == pytest.approx( + [aligner.score(a, b) for a, b in zip(seqsA, seqsB)], rel=1e-12) + + +@pytest.mark.parametrize("num_threads", [1, 4]) +def test_align_pairs_empty_input(num_threads): + assert _align_pairs([], [], _aligner("global"), num_threads) == [] + + +def test_align_pairs_preserves_order_with_distinct_scores(): + aligner = _aligner("global") + seqsA = ["A" * k for k in range(1, 21)] + seqsB = ["A" * k for k in range(1, 21)] + scores = [a.score for a in _align_pairs(seqsA, seqsB, aligner, 4)] + assert scores == sorted(scores) + assert len(set(scores)) == 20 + + +# --- _matrix_alphabet / _warm_start_alphabet -------------------------------- + +def _matrix(alphabet): + n = len(alphabet) + return substitution_matrices.Array(alphabet=alphabet, data=np.eye(n)) + + +def test_matrix_alphabet_of_array(): + assert _matrix_alphabet(_matrix("TGCA")) == "TGCA" + + +@pytest.mark.parametrize("matrix", [None, np.eye(2), 3.0]) +def test_matrix_alphabet_without_alphabet_attribute(matrix): + assert _matrix_alphabet(matrix) is None + + +def test_warm_start_alphabet_from_initial_parameters(): + assert _warm_start_alphabet(None, {"substitution_matrix": _matrix("CGAT")}) == "CGAT" + + +def test_warm_start_alphabet_from_baseline_aligner(): + aligner = PairwiseAligner() + aligner.substitution_matrix = _matrix("TCAG") + assert _warm_start_alphabet(aligner, None) == "TCAG" + + +def test_warm_start_alphabet_prefers_initial_parameters(): + aligner = PairwiseAligner() + aligner.substitution_matrix = _matrix("TCAG") + assert _warm_start_alphabet(aligner, {"substitution_matrix": _matrix("GATC")}) == "GATC" + + +def test_warm_start_alphabet_falls_back_to_aligner_without_matrix_in_params(): + aligner = PairwiseAligner() + aligner.substitution_matrix = _matrix("TCAG") + assert _warm_start_alphabet(aligner, {"match_score": 1.0}) == "TCAG" + + +def test_warm_start_alphabet_none_for_simple_scoring(): + aligner = PairwiseAligner() + aligner.match_score = 2 + assert _warm_start_alphabet(aligner, {"match_score": 2.0}) is None + assert _warm_start_alphabet(None, None) is None diff --git a/tests/test_gradient_consistency.py b/tests/test_gradient_consistency.py new file mode 100644 index 0000000..219b676 --- /dev/null +++ b/tests/test_gradient_consistency.py @@ -0,0 +1,217 @@ +""" +Integration tests tying the subgradient to the alignment scores. + +For a hard (Viterbi) alignment, the score is linear in the parameters with +the path's count vector as coefficients, and the log-likelihood gradient is +sum_i (y_i - p_i) * counts_i. These tests check both facts against +Biopython's scores, at random non-integer parameters where the optimal path +is locally unique. +""" +import itertools + +import numpy as np +import pytest +from Bio.Align import PairwiseAligner, substitution_matrices + +from src.logit_link import logit_subgradient +from tests.helpers import (DNA, GAP_MODES, MODES, SUBSTITUTION_MODES, align_all, + loglik_at, make_aligner, make_pairs, model_gradient, + perturbed, random_params, subgradient_at) + +SEEDS = range(5) + +# Edge-case pairs mixed into every random fixture. +EDGE_PAIRS = [ + ("A", "A"), + ("A", "C"), + ("ACGT", "ACGT"), + ("AAAA", "CCCC"), + ("ACGTACGT", "ACG"), + ("G", "TTGTT"), +] + + +def _fixture(seed, n=6, length=10): + rng = np.random.default_rng(seed) + seqsA, seqsB, labels = make_pairs(rng, n // 2, n - n // 2, length, sub_rate=0.2) + seqsA += [a for a, _ in EDGE_PAIRS] + seqsB += [b for _, b in EDGE_PAIRS] + labels = np.concatenate([labels, rng.integers(0, 2, size=len(EDGE_PAIRS))]) + return rng, seqsA, seqsB, labels + + +def _counts_dot_params(sg, params): + """Score implied by a single alignment's counts (weight 1).""" + G = np.asarray(sg["Substitutions"]) + if "substitution_matrix" in params: + score = np.sum(G * np.asarray(params["substitution_matrix"])) + else: + score = np.trace(G) * params["match_score"] + (G.sum() - np.trace(G)) * params["mismatch_score"] + if "gap_score" in params: + score += (sg["Gap opens"] + sg["Gap extends"]) * params["gap_score"] + else: + score += sg["Gap opens"] * params["open_gap_score"] + sg["Gap extends"] * params["extend_gap_score"] + return score + + +# --- score = counts . parameters -------------------------------------------- + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +@pytest.mark.parametrize("gap_mode", GAP_MODES) +@pytest.mark.parametrize("mode", MODES) +def test_score_equals_counts_dot_parameters(mode, gap_mode, substitution_mode, seed): + rng, seqsA, seqsB, _ = _fixture(seed) + params = random_params(rng, gap_mode, substitution_mode) + for aln in align_all(seqsA, seqsB, make_aligner(mode, params)): + sg = logit_subgradient([aln], [0.0], [1], 0.0, DNA) + assert _counts_dot_params(sg, params) == pytest.approx(aln.score, rel=1e-12, abs=1e-12) + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("mode", MODES) +def test_biopython_counts_match_subgradient_counts(mode, seed): + """The initial estimate's features (aln.counts()) agree with logit_subgradient.""" + rng, seqsA, seqsB, _ = _fixture(seed) + params = random_params(rng, "affine", "simple") + for aln in align_all(seqsA, seqsB, make_aligner(mode, params)): + sg = logit_subgradient([aln], [0.0], [1], 0.0, DNA) + G = np.asarray(sg["Substitutions"]) + counts = aln.counts() + assert counts.identities == np.trace(G) + assert counts.mismatches == G.sum() - np.trace(G) + assert counts.open_gaps == sg["Gap opens"] + assert counts.extend_gaps == sg["Gap extends"] + assert counts.gaps == sg["Gap opens"] + sg["Gap extends"] + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("mode", MODES) +def test_biopython_counts_reproduce_score(mode, seed): + rng, seqsA, seqsB, _ = _fixture(seed) + params = random_params(rng, "affine", "simple") + for aln in align_all(seqsA, seqsB, make_aligner(mode, params)): + c = aln.counts() + implied = (c.identities * params["match_score"] + c.mismatches * params["mismatch_score"] + + c.open_gaps * params["open_gap_score"] + c.extend_gaps * params["extend_gap_score"]) + assert implied == pytest.approx(aln.score, rel=1e-12, abs=1e-12) + + +def test_local_score_is_nonnegative_and_global_is_not_bounded(): + rng = np.random.default_rng(0) + params = random_params(rng, "affine", "simple") + local = make_aligner("local", params) + glob = make_aligner("global", params) + for a, b in [("AAAA", "CCCC"), ("ACAC", "GTGT"), ("A", "G")]: + assert align_all([a], [b], local)[0].score == 0.0 + assert align_all([a], [b], glob)[0].score < 0.0 + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("gap_mode", GAP_MODES) +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +def test_local_score_dominates_global_score(gap_mode, substitution_mode, seed): + rng, seqsA, seqsB, _ = _fixture(seed) + params = random_params(rng, gap_mode, substitution_mode) + local = align_all(seqsA, seqsB, make_aligner("local", params)) + glob = align_all(seqsA, seqsB, make_aligner("global", params)) + for l, g in zip(local, glob): + assert l.score >= g.score - 1e-12 + + +@pytest.mark.xfail(strict=True, reason=( + "BUG: logit_subgradient counts gap-A, gap-B, gap-A as 2 opens + extends; " + "Biopython scores every switch as a new open. Reachable once the extend " + "score drops below the open score.")) +def test_score_decomposition_with_alternating_gaps(): + params = {"match_score": 1.0, "mismatch_score": -100.0, + "open_gap_score": -1.0, "extend_gap_score": -10.0} + aln = align_all(["AB"], ["CD"], make_aligner("global", params))[0] + sg = logit_subgradient([aln], [0.0], [1], 0.0, "ABCD") + assert _counts_dot_params(sg, params) == pytest.approx(aln.score) + + +# --- subgradient = d logL / d theta (central differences) ------------------- + +def _directions(params, substitution_mode, alphabet=DNA): + """(key, index, symmetric) for every free parameter of the model.""" + for key in ("open_gap_score", "extend_gap_score", "gap_score", + "match_score", "mismatch_score"): + if key in params: + yield key, None, False + if "substitution_matrix" in params: + n = len(alphabet) + if substitution_mode == "symmetric": + pairs = [(i, j) for i in range(n) for j in range(i, n)] + else: + pairs = list(itertools.product(range(n), repeat=2)) + for i, j in pairs: + yield "substitution_matrix", (i, j), substitution_mode == "symmetric" + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +@pytest.mark.parametrize("gap_mode", GAP_MODES) +@pytest.mark.parametrize("mode", MODES) +def test_subgradient_matches_finite_differences(mode, gap_mode, substitution_mode, seed): + rng, seqsA, seqsB, labels = _fixture(seed) + params = random_params(rng, gap_mode, substitution_mode) + sg = subgradient_at(seqsA, seqsB, labels, mode, params, DNA) + grad = model_gradient(sg, gap_mode, substitution_mode) + h = 1e-6 + for key, index, symmetric in _directions(params, substitution_mode): + up = loglik_at(seqsA, seqsB, labels, mode, perturbed(params, key, h, index, symmetric)) + down = loglik_at(seqsA, seqsB, labels, mode, perturbed(params, key, -h, index, symmetric)) + numeric = (up - down) / (2 * h) + analytic = grad[key] if index is None else grad[key][index] + assert analytic == pytest.approx(numeric, rel=1e-5, abs=1e-6), (key, index) + + +@pytest.mark.parametrize("mode", MODES) +def test_alpha_derivative_is_sum_of_residuals(mode): + rng, seqsA, seqsB, labels = _fixture(11) + params = random_params(rng, "affine", "simple") + h = 1e-6 + up = loglik_at(seqsA, seqsB, labels, mode, dict(params, alpha=params["alpha"] + h)) + down = loglik_at(seqsA, seqsB, labels, mode, dict(params, alpha=params["alpha"] - h)) + aligner = make_aligner(mode, params) + scores = np.array([a.score for a in align_all(seqsA, seqsB, aligner)]) + residual = np.sum(labels - 1 / (1 + np.exp(-(params["alpha"] + scores)))) + assert residual == pytest.approx((up - down) / (2 * h), rel=1e-5, abs=1e-7) + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("mode", MODES) +def test_score_is_convex_in_parameters(mode, seed): + """Viterbi score is a max of linear functions, hence convex along any line.""" + rng, seqsA, seqsB, _ = _fixture(seed) + p0 = random_params(rng, "affine", "general") + p1 = random_params(rng, "affine", "general") + + def mix(t): + M = (1 - t) * np.asarray(p0["substitution_matrix"]) + t * np.asarray(p1["substitution_matrix"]) + out = {k: (1 - t) * p0[k] + t * p1[k] for k in ("open_gap_score", "extend_gap_score")} + out["substitution_matrix"] = substitution_matrices.Array(alphabet=DNA, data=M) + return out + + def scores(t): + return np.array([a.score for a in align_all(seqsA, seqsB, make_aligner(mode, mix(t)))]) + + s0, s_half, s1 = scores(0.0), scores(0.5), scores(1.0) + assert np.all(s_half <= (s0 + s1) / 2 + 1e-9) + + +def test_symmetric_directional_derivative_counts_both_orientations(): + """For a symmetric matrix, d/dM[a,b] (a != b) sees A->B and B->A substitutions.""" + aligner = PairwiseAligner() + aligner.mode = "global" + aligner.open_gap_score = -10 + aligner.extend_gap_score = -10 + aligner.match_score = 1 + aligner.mismatch_score = -1 + aln = align_all(["AC"], ["CA"], aligner)[0] + sg = logit_subgradient([aln], [0.0], [1], 0.0, DNA) + grad = model_gradient(sg, "affine", "symmetric") + M = grad["substitution_matrix"] + assert M[0, 1] == 2 and M[1, 0] == 2 + assert np.trace(M) == 0 From ffbdac801e7ef28ead77a376c8ee180b0ee0f19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 09:26:11 +0200 Subject: [PATCH 06/65] Add integration tests for discrimalign() The core check: one iteration from known parameters equals theta0 plus step times the model gradient, recomputed independently from Biopython alignments at the returned alpha, in all twelve mode combinations. Also covers result structure and consistency, zero-step and zero-iteration runs, input immutability, thread-count and subgradient_scale invariance, seeded noise, alphabet inference and default baselines. test_default_stepfunction_runs is a strict xfail: stepfunction defaults to None and is called unconditionally. --- tests/__pycache__/helpers.cpython-314.pyc | Bin 10230 -> 10181 bytes ..._discrimalign.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 69718 bytes tests/helpers.py | 16 +- tests/test_discrimalign.py | 422 ++++++++++++++++++ 4 files changed, 426 insertions(+), 12 deletions(-) create mode 100644 tests/__pycache__/test_discrimalign.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/test_discrimalign.py diff --git a/tests/__pycache__/helpers.cpython-314.pyc b/tests/__pycache__/helpers.cpython-314.pyc index bebd599b012e3241277bcff47ab278648bd609e8..142248a864304c81250a3a0c46dd250aca1ee30f 100644 GIT binary patch delta 2222 zcmaJ?O>7fK6y90e>)6JQlQ?l45@Od7*pNCE6#7GwQdB~yfK(fjQbaIgdt;WYz02%c zO(Ine5-L@z3ei2ZDy2V%9wJpOmDN+FiVITJ6SSp=RZ%(6Q`M?Kg{XSzd$UdomyWdG z&YOAj=FNNay}3VmbuzX!77Yu~{wjZc>ZjC&SU0(KVe{VlaY2#xubYV4(SAV*+A*a? z3FSn)RS7E*_{VdC(lbfyw%OQrqO3~_IX5(Aji3!0GVtTl{PMQ zD(y+Z?ov7clS%@xTj>N$DeIN4WCjiPC`lMtuXF?UD!odIFYHrDR*?JNA^!xW0#mE@ z>Ba+cfCReX5S@+vzPu>MpeqMliP37wsJId}O|@EL?J&$b5ZX{7#1hW+KtJhn?gX;n zj3BsDrDjuGWg9_AH3p=HFM?)SFTzIWb|{-opkAPwHw_DaRkh4IakdL#146$u815r| z&O|uV#jS=bbY9JyRAUy~4jO}vdN>jw5$CPelO3%v;D)TlN=3DqR$LKTmm|f`NL$d9 z?7SV%l7#bRJhPi`umiOvlQLSdTvBCYAX_Z}1K85(G~+u)y_3V9sK{Ofh0#WP+a5wv zjp=rU)SreCwiDqggy#^RMc4%(w=fZYuD@cGRQCU>K6U_?O`v{|>RP#~GRp@Nc;or9 zj-S3c#SX#LD}pnSI7d>>=ZQCV1z|uAu*c!YatH?jTtZz^umaHK$MP(NxRE^7b#@qL zPC2JLe=EEMJy?%+nT#-qfb(v^DjH0crH72KIqPlXqk7enK-Y_2a~rAm`Qb}DfA8}(C*R1*C zlBG2#R-=Xsb!{Gnt0dDYgc<@6SCUY2KJhT$67#}w>iNL|TG zop%A3m%V(pjOesdDA6&u-!N^hLUp)8aG2bPZWO6*jX}p#I14r`H=G=c13e#7=_xGJMuE0DmtFx1+7+kG^^IJ;`~uH`)Djr0qug*tN*? zmE`x4>2GV-Bh%OX(=64<^j#1u5m>Sm+DFZ_0V%OKy_X^=s|z;d$nv<3e0HN#wSr}9 zcFoodP{rN;<3Afg5o-cmTFxmvmK(4k@hanyf5GWbr=)Sz+vkj>lRdob#IRuK`7)y= z4Q>Q8Vg8(Bq<@F9Gn;8AGe{yi=#dpE>RIQEok0;3%8-w8i7YmYhjwMPvs)GqjmsO{U_M_miupXeE47Nb$b;jC_Gm`#c`U5FD0A1{m`5~z zcYtX8vbj-j&@$C+MUCn_n4rmuIUuoS=XI?*&x7m6aj?0V;tgOy=evR7dN;Vr-#-3K z@n_?qT=7D80#)#q^5pUW^K-8VoEEE$gNsnp)iL%KXafBO>k`19K0?SH(NFyM!UE~| sMHsm$WNr%mzY0S)g(q%FPHnT6@{<{IH`YPocZUMxFu5O(l2&f|Ux4KdM*si- delta 2247 zcmaJ?O>7%Q6yCAD>-abRiGPx~sS_(nR|ziApY$hf1GK5C{sgwgDZ;JU*o(7`f0o%b zO>!Vbg4QZ(n`#uPsNzr|4n1%HA31QL;##Q{3acVOAM)K@?v?s1Pfp_>}8f$LNbD!Vt(>>E9^K%!~}TU)haj(4iaGBit$Jo7H9-X z2#D;VQTABeN@C0{Wg>A{vZZRnQZ0>UfJwH-rIr(}0Q$X1GVCu;CKE+Hu~snZCQdc2 zOuJATL$U=)ADi%QAt`p=+aKpvy;XHiD;TOyP5Lru$gLHxPb6M;r*k$MgazAUE>x?U zMfKuc2aT~*=tF53O>JYJhcYC}z7O?J@B?}Ako>C*sn8F@CWdhr$WVv8kf!O$K9!v`2g9ey(0-&8487w92avYlq5 zYI`d6vRW!>^Tu;-WO2`iFE&}+Z~~*ozUqz=%I0_gb zO}r}~Sm$z{^?izC543I>?w%5kUlfF`Le7;DJVGCzW#{6=(kbntRj&=3X=tU{dMRD6 zrL9>lt=FKVi<)7L!f#14G$1uAU4J)i7IkAGTh(iNRbSMy^es5He0${1<=Y=g2V{4< zp7OBBGfD^X4`WTw0kNIxj469LxMki=>+BVhwY4`^<(N|#}azJ=sBty z={khc3QpP~d4xDbxn3ireEWHk@dtmJu%+USFBJ@CS@YdxK6fLS2 zH6CL68X9qxbhRvtw$E(LlqvgZt9$GS?wsVpGF3}9Ir?v`2qJb>3Mkbf+mwRx4c43f z4G_@NA0au+45XOj2BO6#@OTqho@cCI*|!Nlo^RYX=ySkwA(GG>dv9R4heL3Xd)q!8 zq2zrq^+oIJff2{jI9iqPUPX&yaOq(r9N;$~44x+rs?IrW0Z>P)SSl11l2xo#e%{|U=4XahDNOhozY4Z>*!1P%*#+p`N8G^Oswo|X9V+LyUQ9t zw_1@M^ljjODWQVn)TErU-GxG_UMv*gy=zpB1$dQ4hPnp>?d~xRt*EON9xpp+(qa}M zPqzvcy*5Wh_Q6n`NNi diff --git a/tests/__pycache__/test_discrimalign.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_discrimalign.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..812130a8c20eeb607b7ea0df7ac2d916d471af59 GIT binary patch literal 69718 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 literal 0 HcmV?d00001 diff --git a/tests/helpers.py b/tests/helpers.py index 6b726ab..2106a92 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -5,6 +5,7 @@ """ import numpy as np from Bio.Align import PairwiseAligner, substitution_matrices +from scipy.optimize import brentq from scipy.special import expit from src.logit_link import logit_subgradient @@ -124,20 +125,11 @@ def loglik_at(seqsA, seqsB, labels, mode, params): return float(np.sum(labels * z - np.logaddexp(0.0, z))) -def optimal_alpha(scores, labels, alpha0=0.0, iters=100): - """Newton's method on the intercept, independent of scipy.minimize.""" +def optimal_alpha(scores, labels, bracket=(-200.0, 200.0)): + """Root of d logL / d alpha, by bracketing (independent of scipy.minimize).""" scores = np.asarray(scores, dtype=float) labels = np.asarray(labels, dtype=float) - alpha = alpha0 - for _ in range(iters): - p = expit(alpha + scores) - g = np.sum(labels - p) - h = -np.sum(p * (1 - p)) - step = g / h - alpha -= step - if abs(step) < 1e-14: - break - return alpha + return brentq(lambda a: np.sum(labels - expit(a + scores)), *bracket, xtol=1e-14) def model_gradient(subgradient, gap_mode, substitution_mode): diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py new file mode 100644 index 0000000..be43ae9 --- /dev/null +++ b/tests/test_discrimalign.py @@ -0,0 +1,422 @@ +"""Integration tests for src.discrimalign.discrimalign on small fixtures.""" +import warnings +from copy import deepcopy + +import numpy as np +import pytest +from Bio.Align import PairwiseAligner, substitution_matrices +from scipy.special import expit + +from src.discrimalign import discrimalign +from src.logit_link import logit_logL, logit_subgradient +from src.optimization import (EmptyLocalAlignment, create_constant_step, + create_powerstep, get_initial_estimate) +from tests.helpers import (DNA, GAP_MODES, MODES, SUBSTITUTION_MODES, align_all, + make_aligner, make_pairs, model_gradient, optimal_alpha, + random_params) + +ALL_MODES = [(m, g, s) for m in MODES for g in GAP_MODES for s in SUBSTITUTION_MODES] + + +def _data(seed=0, n=12, length=12): + rng = np.random.default_rng(seed) + seqsA, seqsB, labels = make_pairs(rng, n // 2, n - n // 2, length, sub_rate=0.25) + # Flip one label per class so the classes overlap and the intercept MLE exists. + labels[np.flatnonzero(labels == 1)[0]] = 0 + labels[np.flatnonzero(labels == 0)[0]] = 1 + return rng, seqsA, seqsB, labels + + +def _param_keys(gap_mode, substitution_mode): + keys = {"open_gap_score", "extend_gap_score"} if gap_mode == "affine" else {"gap_score"} + keys |= {"match_score", "mismatch_score"} if substitution_mode == "simple" else {"substitution_matrix"} + return keys + + +def _run(seqsA, seqsB, labels, mode, gap_mode, substitution_mode, **kwargs): + kwargs.setdefault("stepfunction", create_constant_step(0.01)) + kwargs.setdefault("max_iter", 2) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # sklearn convergence noise on tiny data + return discrimalign(seqsA, seqsB, labels, aligner_mode=mode, gap_mode=gap_mode, + substitution_mode=substitution_mode, **kwargs) + + +def _assert_alpha_stationary(alpha, scores, labels): + """ + dlogL/dalpha vanishes at alpha, to scipy.minimize's default gtol (1e-5). + Where the likelihood is flat in alpha this is looser than it looks in + alpha itself, so alpha is also compared loosely with the exact root. + """ + assert abs(np.sum(labels - expit(alpha + scores))) < 1e-4 + assert alpha == pytest.approx(optimal_alpha(scores, labels), abs=1e-2) + + +def _assert_params_equal(result, expected, keys, **tol): + for key in keys: + np.testing.assert_allclose(np.asarray(result[key]), np.asarray(expected[key]), + err_msg=key, **tol) + + +# --- argument validation ---------------------------------------------------- + +@pytest.mark.parametrize("kwargs", [ + {"aligner_mode": "semiglobal"}, + {"gap_mode": "convex"}, + {"substitution_mode": "blosum"}, +]) +def test_rejects_unknown_modes(kwargs): + _, A, B, y = _data() + with pytest.raises(AssertionError): + discrimalign(A, B, y, stepfunction=create_constant_step(0.1), max_iter=1, **kwargs) + + +@pytest.mark.xfail(strict=True, raises=TypeError, reason=( + "BUG: stepfunction defaults to None and is called unconditionally, so " + "discrimalign() with default arguments crashes on the first iteration.")) +def test_default_stepfunction_runs(): + _, A, B, y = _data() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + discrimalign(A, B, y, max_iter=1) + + +def test_single_class_labels_without_initial_parameters_raise(): + _, A, B, _ = _data() + with pytest.raises(ValueError): + _run(A, B, np.ones(len(A), dtype=int), "local", "affine", "simple") + + +# --- result structure ------------------------------------------------------- + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_result_structure(mode, gap_mode, substitution_mode): + _, A, B, y = _data() + res = _run(A, B, y, mode, gap_mode, substitution_mode, max_iter=3) + expected = _param_keys(gap_mode, substitution_mode) | { + "loglik_trajectory", "subgradient_l2_trajectory", "final_loglik", + "aligner", "alignments", "alignment_logit_scores", "alpha"} + assert set(res) == expected + assert len(res["loglik_trajectory"]) == 4 + assert len(res["subgradient_l2_trajectory"]) == 3 + assert res["final_loglik"] == res["loglik_trajectory"][-1] + assert len(res["alignments"]) == len(A) + assert len(res["alignment_logit_scores"]) == len(A) + assert np.all(np.asarray(res["subgradient_l2_trajectory"]) >= 0) + assert np.all(np.asarray(res["loglik_trajectory"]) <= 0) + assert res["aligner"].mode == mode + if substitution_mode != "simple": + assert "".join(res["substitution_matrix"].alphabet) == DNA + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_final_outputs_are_consistent(mode, gap_mode, substitution_mode): + rng, A, B, y = _data(1) + p0 = random_params(rng, gap_mode, substitution_mode) + res = _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0) + realigned = align_all(A, B, res["aligner"]) + scores = np.array([a.score for a in res["alignments"]]) + np.testing.assert_array_equal(scores, [a.score for a in realigned]) + np.testing.assert_allclose(res["alignment_logit_scores"], expit(res["alpha"] + scores), rtol=1e-14) + assert res["final_loglik"] == pytest.approx(logit_logL(res["alignment_logit_scores"], y), rel=1e-14) + # The returned aligner carries the returned parameters. + reference = make_aligner(mode, {k: res[k] for k in _param_keys(gap_mode, substitution_mode)}) + assert [a.score for a in realigned] == [a.score for a in align_all(A, B, reference)] + + +def test_return_alignments_false_omits_alignments(): + _, A, B, y = _data() + res = _run(A, B, y, "local", "affine", "simple", return_alignments=False) + assert "alignments" not in res + assert len(res["alignment_logit_scores"]) == len(A) + + +# --- zero iterations and zero steps ----------------------------------------- + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_zero_iterations_return_initial_parameters(mode, gap_mode, substitution_mode): + rng, A, B, y = _data(2) + p0 = random_params(rng, gap_mode, substitution_mode) + res = _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, + max_iter=0, stepfunction=None) + _assert_params_equal(res, p0, _param_keys(gap_mode, substitution_mode)) + assert res["alpha"] == p0["alpha"] + assert res["subgradient_l2_trajectory"] == [] + scores = np.array([a.score for a in align_all(A, B, make_aligner(mode, p0))]) + assert res["final_loglik"] == pytest.approx(logit_logL(expit(p0["alpha"] + scores), y)) + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_zero_step_keeps_parameters_and_fits_alpha(mode, gap_mode, substitution_mode): + rng, A, B, y = _data(3) + p0 = random_params(rng, gap_mode, substitution_mode) + res = _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, + max_iter=3, stepfunction=create_constant_step(0.0)) + _assert_params_equal(res, p0, _param_keys(gap_mode, substitution_mode)) + scores = np.array([a.score for a in res["alignments"]]) + _assert_alpha_stationary(res["alpha"], scores, y) + traj = res["loglik_trajectory"] + assert traj[1] >= traj[0] - 1e-12 + assert traj[1] == pytest.approx(traj[2], abs=1e-8) + assert traj[2] == pytest.approx(traj[3], abs=1e-8) + + +# --- one iteration is exactly one subgradient step -------------------------- + +@pytest.mark.parametrize("seed", [4, 5]) +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_one_iteration_is_one_subgradient_step(mode, gap_mode, substitution_mode, seed): + rng, A, B, y = _data(seed) + p0 = random_params(rng, gap_mode, substitution_mode) + eta = 0.013 + res = _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, + max_iter=1, stepfunction=create_constant_step(eta)) + + alns = align_all(A, B, make_aligner(mode, p0)) + scores = np.array([a.score for a in alns]) + alpha = res["alpha"] + # alpha is the intercept MLE for the scores under p0 ... + _assert_alpha_stationary(alpha, scores, y) + # ... and the step is taken with the gradient at that alpha. + logits = expit(alpha + scores) + grad = model_gradient(logit_subgradient(alns, logits, y, alpha, DNA), gap_mode, substitution_mode) + expected = {k: np.asarray(p0[k]) + eta * np.asarray(grad[k]) for k in grad} + _assert_params_equal(res, expected, grad.keys(), rtol=1e-12, atol=1e-12) + + norm = np.sqrt(sum(np.sum(np.asarray(g) ** 2) for g in grad.values())) + assert res["subgradient_l2_trajectory"][0] == pytest.approx(norm, rel=1e-12) + assert res["loglik_trajectory"][0] == pytest.approx(logit_logL(expit(p0["alpha"] + scores), y)) + + +@pytest.mark.parametrize("mode", MODES) +def test_small_step_increases_likelihood_at_fixed_alpha(mode): + """A short step along the subgradient is an ascent direction.""" + rng, A, B, y = _data(6, n=16) + p0 = random_params(rng, "affine", "general") + res = _run(A, B, y, mode, "affine", "general", initial_parameters=p0, + max_iter=1, stepfunction=create_constant_step(1e-4)) + p0_fitted = dict(p0, alpha=res["alpha"]) + p1 = {k: res[k] for k in _param_keys("affine", "general")} + p1["alpha"] = res["alpha"] + + def loglik(p): + s = np.array([a.score for a in align_all(A, B, make_aligner(mode, p))]) + return logit_logL(expit(p["alpha"] + s), y) + + assert loglik(p1) > loglik(p0_fitted) + + +# --- invariants over several iterations ------------------------------------- + +@pytest.mark.parametrize("mode, gap_mode", [(m, g) for m in MODES for g in GAP_MODES]) +def test_symmetric_mode_keeps_matrix_symmetric(mode, gap_mode): + _, A, B, y = _data(7) + res = _run(A, B, y, mode, gap_mode, "symmetric", max_iter=4, + stepfunction=create_powerstep(0.05)) + M = np.asarray(res["substitution_matrix"]) + np.testing.assert_allclose(M, M.T, atol=1e-12) + + +def test_general_mode_can_become_asymmetric(): + rng, A, B, y = _data(8) + p0 = random_params(rng, "affine", "symmetric") + res = _run(A, B, y, "global", "affine", "general", initial_parameters=p0, + max_iter=2, stepfunction=create_constant_step(0.05)) + M = np.asarray(res["substitution_matrix"]) + assert not np.allclose(M, M.T) + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_inputs_are_not_mutated(mode, gap_mode, substitution_mode): + rng, A, B, y = _data(9) + p0 = random_params(rng, gap_mode, substitution_mode) + baseline = make_aligner(mode, random_params(rng, gap_mode, substitution_mode)) + snapshot = (deepcopy(p0), list(A), list(B), y.copy(), + [a.score for a in align_all(A, B, baseline)]) + _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, + baseline_aligner=baseline, max_iter=2, stepfunction=create_constant_step(0.1)) + _assert_params_equal(p0, snapshot[0], p0.keys()) + assert (A, B) == (snapshot[1], snapshot[2]) + np.testing.assert_array_equal(y, snapshot[3]) + assert [a.score for a in align_all(A, B, baseline)] == snapshot[4] + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", + [("local", "affine", "symmetric"), ("global", "linear", "simple")]) +def test_thread_count_does_not_change_results(mode, gap_mode, substitution_mode): + _, A, B, y = _data(10, n=20) + one = _run(A, B, y, mode, gap_mode, substitution_mode, max_iter=3, num_threads=1) + four = _run(A, B, y, mode, gap_mode, substitution_mode, max_iter=3, num_threads=4) + _assert_params_equal(four, one, _param_keys(gap_mode, substitution_mode), rtol=0, atol=0) + assert four["loglik_trajectory"] == one["loglik_trajectory"] + + +@pytest.mark.parametrize("gap_mode, substitution_mode", + [("affine", "simple"), ("linear", "general"), ("affine", "symmetric")]) +def test_subgradient_scale_is_equivalent_to_scaled_step(gap_mode, substitution_mode): + rng, A, B, y = _data(11) + p0 = random_params(rng, gap_mode, substitution_mode) + scaled = _run(A, B, y, "local", gap_mode, substitution_mode, initial_parameters=p0, + max_iter=3, subgradient_scale=0.5, stepfunction=create_constant_step(0.02)) + plain = _run(A, B, y, "local", gap_mode, substitution_mode, initial_parameters=p0, + max_iter=3, stepfunction=create_constant_step(0.01)) + _assert_params_equal(scaled, plain, _param_keys(gap_mode, substitution_mode), rtol=1e-12) + np.testing.assert_allclose(scaled["subgradient_l2_trajectory"], + 0.5 * np.asarray(plain["subgradient_l2_trajectory"]), rtol=1e-12) + + +# --- stochastic_factor ------------------------------------------------------ + +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +def test_stochastic_factor_is_reproducible_under_global_seed(substitution_mode): + rng, A, B, y = _data(12) + p0 = random_params(rng, "affine", substitution_mode) + runs = [] + for _ in range(2): + np.random.seed(123) + runs.append(_run(A, B, y, "local", "affine", substitution_mode, initial_parameters=p0, + stochastic_factor=0.5, max_iter=2)) + keys = _param_keys("affine", substitution_mode) + _assert_params_equal(runs[0], runs[1], keys, rtol=0, atol=0) + quiet = _run(A, B, y, "local", "affine", substitution_mode, initial_parameters=p0, max_iter=2) + assert any(not np.allclose(np.asarray(runs[0][k]), np.asarray(quiet[k])) for k in keys) + + +def test_symmetric_mode_stays_symmetric_with_noise(): + rng, A, B, y = _data(13) + np.random.seed(7) + res = _run(A, B, y, "global", "affine", "symmetric", + initial_parameters=random_params(rng, "affine", "symmetric"), + stochastic_factor=1.0, max_iter=3) + M = np.asarray(res["substitution_matrix"]) + np.testing.assert_allclose(M, M.T, atol=1e-12) + + +# --- alphabet handling ------------------------------------------------------ + +def test_alphabet_inferred_from_sequences_is_sorted_union(): + A = ["GATTACA", "CAT", "TAG", "GGT"] + B = ["GATACA", "CAT", "ACG", "TTT"] + res = _run(A, B, np.array([1, 1, 0, 0]), "local", "affine", "general", max_iter=1) + assert "".join(res["substitution_matrix"].alphabet) == "ACGT" + + +def test_alphabet_taken_from_initial_parameters(): + rng, A, B, y = _data(14) + p0 = random_params(rng, "affine", "general", alphabet="TGCA") + res = _run(A, B, y, "local", "affine", "general", initial_parameters=p0, max_iter=1) + assert "".join(res["substitution_matrix"].alphabet) == "TGCA" + + +def test_alphabet_taken_from_baseline_aligner(): + rng, A, B, y = _data(15) + baseline = make_aligner("local", random_params(rng, "affine", "general", alphabet="CATG")) + res = _run(A, B, y, "local", "affine", "general", baseline_aligner=baseline, max_iter=1) + assert "".join(res["substitution_matrix"].alphabet) == "CATG" + + +def test_explicit_alphabet_wins(): + _, A, B, y = _data(16) + res = _run(A, B, y, "local", "affine", "general", alphabet="GTAC", max_iter=1) + assert "".join(res["substitution_matrix"].alphabet) == "GTAC" + + +def test_explicit_alphabet_missing_a_letter_fails(): + _, A, B, y = _data(17) + with pytest.raises((KeyError, IndexError, ValueError)): + _run(A, B, y, "local", "affine", "general", alphabet="ACG", max_iter=1) + + +def test_protein_alphabet(): + rng = np.random.default_rng(18) + protein = "ACDEFGHIKLMNPQRSTVWY" + A, B, y = make_pairs(rng, 5, 5, 15, alphabet=protein, sub_rate=0.2) + res = _run(A, B, y, "local", "affine", "symmetric", alphabet=protein, max_iter=2) + assert np.asarray(res["substitution_matrix"]).shape == (20, 20) + + +# --- baselines used for the initial estimate -------------------------------- + +def _default_baseline(mode, gap_mode, substitution_mode, alphabet=DNA): + aligner = PairwiseAligner() + aligner.mode = mode + if gap_mode == "affine": + aligner.open_gap_score = -8 + aligner.extend_gap_score = -0.5 + else: + aligner.gap_score = -6 + if substitution_mode == "simple": + aligner.match_score = 5 + aligner.mismatch_score = -4 + else: + aligner.substitution_matrix = substitution_matrices.Array( + data=9 * np.eye(len(alphabet)) - 4, alphabet=alphabet) + return aligner + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_initial_estimate_uses_default_baseline(mode, gap_mode, substitution_mode): + _, A, B, y = _data(19, n=20) + res = _run(A, B, y, mode, gap_mode, substitution_mode, max_iter=0, stepfunction=None) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + expected = get_initial_estimate( + align_all(A, B, _default_baseline(mode, gap_mode, substitution_mode)), y, + substitution_mode=substitution_mode, gap_mode=gap_mode, alphabet=DNA) + _assert_params_equal(res, expected, _param_keys(gap_mode, substitution_mode), rtol=1e-12) + assert res["alpha"] == pytest.approx(expected["alpha"], rel=1e-12) + + +def test_initial_estimate_uses_given_baseline_aligner(): + rng, A, B, y = _data(20, n=20) + baseline = make_aligner("global", random_params(rng, "linear", "simple")) + res = _run(A, B, y, "global", "linear", "simple", baseline_aligner=baseline, + max_iter=0, stepfunction=None) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + expected = get_initial_estimate(align_all(A, B, baseline), y, "simple", "linear") + _assert_params_equal(res, expected, {"match_score", "mismatch_score", "gap_score"}, rtol=1e-12) + + +# --- data edge cases -------------------------------------------------------- + +@pytest.mark.parametrize("gap_mode, substitution_mode", [("affine", "simple"), ("linear", "general")]) +def test_local_pairs_without_alignment(gap_mode, substitution_mode): + A = ["AAAA", "ACGT", "CCCC", "ACGTAC", "GGGG", "ACGA"] + B = ["CCCC", "ACGT", "GGGG", "ACGTAC", "TTTT", "ACGT"] + y = np.array([0, 1, 0, 1, 0, 1]) + rng = np.random.default_rng(21) + p0 = random_params(rng, gap_mode, substitution_mode) + res = _run(A, B, y, "local", gap_mode, substitution_mode, initial_parameters=p0, + max_iter=2) + for i in (0, 2, 4): + assert isinstance(res["alignments"][i], EmptyLocalAlignment) + assert res["alignment_logit_scores"][i] == pytest.approx(expit(res["alpha"])) + + +def test_single_residue_sequences(): + A = ["A", "C", "G", "T", "A", "C"] + B = ["A", "C", "G", "A", "C", "T"] + y = np.array([1, 1, 1, 0, 0, 0]) + rng = np.random.default_rng(22) + res = _run(A, B, y, "global", "affine", "general", + initial_parameters=random_params(rng, "affine", "general"), max_iter=3) + assert np.all(np.isfinite(np.asarray(res["substitution_matrix"]))) + + +def test_list_labels_behave_like_array_labels(): + rng, A, B, y = _data(23) + p0 = random_params(rng, "affine", "simple") + as_array = _run(A, B, y, "local", "affine", "simple", initial_parameters=p0, max_iter=2) + as_list = _run(A, B, list(y), "local", "affine", "simple", initial_parameters=p0, max_iter=2) + _assert_params_equal(as_list, as_array, _param_keys("affine", "simple"), rtol=0, atol=0) + + +def test_verbose_prints_progress(capsys): + rng, A, B, y = _data(24) + _run(A, B, y, "local", "affine", "simple", + initial_parameters=random_params(rng, "affine", "simple"), max_iter=1, verbose=True) + out = capsys.readouterr().out + assert "Alphabet:" in out + assert "Start of iteration 0" in out + assert "End of iteration 0" in out From 761be7d791c8a76bbd72ef5e04c2e7a1c58b7d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 09:29:25 +0200 Subject: [PATCH 07/65] Add slow end-to-end learning tests and document running the suite The learning tests fit every mode combination on simulated homologs, check that the fit improves and generalizes to held-out pairs, and check that a planted A<->G substitution preference is recovered. --- README.md | 12 ++++++ tests/test_learning.py | 92 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/test_learning.py diff --git a/README.md b/README.md index c783299..b78eee4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ DiscrimAlign is a research codebase for discriminatively learning alignment para ```text src/ Core DiscrimAlign implementation +tests/ Unit and integration tests (pytest) Simulation experiments.ipynb Simulation experiments for the manuscript pyproject.toml Project environment managed by uv case_study_for_mirna/ miRNA case study, trained models, and evaluation instructions @@ -104,6 +105,17 @@ The returned object contains the fitted aligner, learned alignment parameters, i Parallel alignment during fitting is chunked when `num_threads > 1`. Each joblib task processes a chunk of sequence pairs rather than a single pair, which reduces scheduler overhead across repeated optimization iterations while preserving alignment order. Thread-based joblib workers are used for the chunked alignment tasks. +## Running tests + +`uv sync` installs `pytest` with the default `dev` dependency group. From the repository root: + +```bash +uv run pytest # full suite, about 30 s +uv run pytest -m "not slow" # skip the end-to-end learning runs +``` + +Tests marked `xfail(strict=True)` document known bugs; they start failing once the bug is fixed, and the marker should then be removed. + ## miRNA case study The miRNA case study, trained models, and instructions for reproducing the manuscript AUPRC metrics are documented in: diff --git a/tests/test_learning.py b/tests/test_learning.py new file mode 100644 index 0000000..f059a54 --- /dev/null +++ b/tests/test_learning.py @@ -0,0 +1,92 @@ +""" +End-to-end learning on simulated data. These are the only tests that need +more than a handful of pairs, and they are marked slow. +""" +import warnings + +import numpy as np +import pytest +from sklearn.metrics import roc_auc_score + +from src.discrimalign import discrimalign +from src.optimization import create_powerstep +from tests.helpers import (GAP_MODES, MODES, SUBSTITUTION_MODES, align_all, make_pairs, + mutate, random_seq) + +pytestmark = pytest.mark.slow + +ALL_MODES = [(m, g, s) for m in MODES for g in GAP_MODES for s in SUBSTITUTION_MODES] + + +def _fit(A, B, y, mode, gap_mode, substitution_mode, max_iter=30, scale=1e-3): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return discrimalign(A, B, y, aligner_mode=mode, gap_mode=gap_mode, + substitution_mode=substitution_mode, + stepfunction=create_powerstep(scale), max_iter=max_iter) + + +def _auc(aligner, A, B, y): + return roc_auc_score(y, [a.score for a in align_all(A, B, aligner)]) + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_learning_improves_fit_and_generalizes(mode, gap_mode, substitution_mode): + rng = np.random.default_rng(0) + A, B, y = make_pairs(rng, 40, 40, 40, sub_rate=0.3, indel_rate=0.1) + res = _fit(A, B, y, mode, gap_mode, substitution_mode) + + traj = res["loglik_trajectory"] + assert res["final_loglik"] > traj[0] + assert np.all(np.isfinite(traj)) + assert _auc(res["aligner"], A, B, y) >= 0.95 + + held_out = make_pairs(np.random.default_rng(1), 20, 20, 40, sub_rate=0.3, indel_rate=0.1) + assert _auc(res["aligner"], *held_out) >= 0.9 + + if substitution_mode == "simple": + assert res["match_score"] > res["mismatch_score"] + else: + M = np.asarray(res["substitution_matrix"]) + assert np.min(np.diag(M)) > np.mean(M[~np.eye(4, dtype=bool)]) + # In global mode, adding c to every substitution score and c/2 to every + # gap column shifts each score by c * (len A + len B) / 2. With equal + # lengths alpha absorbs that, so absolute signs are not identified. + # Local alignment breaks the symmetry. + if mode == "local": + if gap_mode == "affine": + assert res["open_gap_score"] < 0 + assert res["extend_gap_score"] < 0 + else: + assert res["gap_score"] < 0 + if substitution_mode == "simple": + assert res["match_score"] > 0 > res["mismatch_score"] + + +_TRANSITION = {"A": "G", "G": "A", "C": "C", "T": "T"} + + +def _transitions_only(rng, seq, rate): + return "".join(_TRANSITION[c] if c in "AG" and rng.random() < rate else c for c in seq) + + +@pytest.mark.parametrize("substitution_mode", ["symmetric", "general"]) +def test_planted_substitution_is_recovered(substitution_mode): + """Homologs differ only by A<->G transitions, so A-G must outscore every other mismatch.""" + rng = np.random.default_rng(0) + A, B, y = [], [], [] + for _ in range(40): + seq = random_seq(rng, 40) + A.append(seq) + B.append(mutate(rng, _transitions_only(rng, seq, 0.5), sub_rate=0.0, indel_rate=0.05)) + y.append(1) + A.append(random_seq(rng, 40)) + B.append(random_seq(rng, 40)) + y.append(0) + res = _fit(A, B, np.array(y), "local", "affine", substitution_mode) + + M = res["substitution_matrix"] + others = [M[a, b] for a in "ACGT" for b in "ACGT" + if a != b and {a, b} != {"A", "G"}] + assert min(M["A", "G"], M["G", "A"]) > max(others) + 0.3 + assert min(M["A", "G"], M["G", "A"]) > 0 From 9ddaaa429f0ceab860f5a5f1ac52d94ac0517961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 10:17:26 +0200 Subject: [PATCH 08/65] Fix gap open/extend counting when gaps alternate between sequences logit_subgradient walks each alignment column by column and classifies every gap column as an open or an extend, using one in-gap flag per sequence. Only a match/mismatch column cleared the flags. A gap in one sequence did not clear the other sequence's flag, so a run such as seq1: - A - B seq2: C - D - was counted as 2 opens + 2 extends. The third column was taken to continue the gap that started in column 1, although a gap in seq2 had closed it in between. Biopython's PairwiseAligner scores every switch between the two sequences' gaps as a new open, so this alignment scores 4 x open. The subgradient therefore was not the count vector of the path that produced the score, and the open/extend components of the gradient were wrong. The situation is unreachable while extend_gap_score >= open_gap_score. Any gapX, gapY, gapX run can be reordered to gapX, gapX, gapY with the same residues and substitution columns, at the cost of open + extend + open instead of three opens, so the optimum never needs to alternate. Nothing in the subgradient iteration constrains the two scores, though, and once the fit drifted to extend < open the gradient reported phantom extensions and pushed extend_gap_score in response to gaps that no alignment contained. Linear gap mode was unaffected, since it only uses opens + extends, the number of gap columns. The initial estimate was also unaffected, since it uses Biopython's own aln.counts(), which was already correct. The fix resets the other sequence's flag in each gap branch, so a gap column continues a gap only if the previous column was a gap in the same sequence. The two strict-xfail regression tests now pass, and their markers are removed. --- src/logit_link.py | 2 ++ tests/test_gradient_consistency.py | 5 +---- tests/test_logit_link.py | 5 +---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/logit_link.py b/src/logit_link.py index a366a08..e21e311 100644 --- a/src/logit_link.py +++ b/src/logit_link.py @@ -76,11 +76,13 @@ def logit_subgradient(alignment_list, logit_scores, else: subgradient['Gap opens'] += weight ingap1 = True + ingap2 = False elif char2 == '-': if ingap2: subgradient['Gap extends'] += weight else: subgradient['Gap opens'] += weight + ingap1 = False ingap2 = True else: ingap1 = False diff --git a/tests/test_gradient_consistency.py b/tests/test_gradient_consistency.py index 219b676..1ef8fe5 100644 --- a/tests/test_gradient_consistency.py +++ b/tests/test_gradient_consistency.py @@ -119,11 +119,8 @@ def test_local_score_dominates_global_score(gap_mode, substitution_mode, seed): assert l.score >= g.score - 1e-12 -@pytest.mark.xfail(strict=True, reason=( - "BUG: logit_subgradient counts gap-A, gap-B, gap-A as 2 opens + extends; " - "Biopython scores every switch as a new open. Reachable once the extend " - "score drops below the open score.")) def test_score_decomposition_with_alternating_gaps(): + """With extend below open, Biopython returns -A-B / C-D-: four opens, no extends.""" params = {"match_score": 1.0, "mismatch_score": -100.0, "open_gap_score": -1.0, "extend_gap_score": -10.0} aln = align_all(["AB"], ["CD"], make_aligner("global", params))[0] diff --git a/tests/test_logit_link.py b/tests/test_logit_link.py index efa6220..a7eceea 100644 --- a/tests/test_logit_link.py +++ b/tests/test_logit_link.py @@ -184,11 +184,8 @@ def test_subgradient_gap_counts(aln, opens, extends): assert sg["Gap extends"] == extends -@pytest.mark.xfail(strict=True, reason=( - "BUG: a gap in one sequence does not reset the other sequence's in-gap " - "flag, so gap-in-A, gap-in-B, gap-in-A is counted as 2 opens + 1 extend " - "instead of 3 opens. Biopython scores it as 3 opens.")) def test_subgradient_alternating_gaps_are_all_opens(): + """Switching between gap-in-A and gap-in-B opens a new gap each time.""" sg = _single(("-A-", "C-G")) assert sg["Gap opens"] == 3 assert sg["Gap extends"] == 0 From 771dd0f6ee4de669aed8ad788db05398dd4a1b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 10:23:07 +0200 Subject: [PATCH 09/65] Raise a clear ValueError when discrimalign() has no stepfunction --- src/discrimalign.py | 6 ++++++ tests/test_discrimalign.py | 26 +++++++++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 69decdf..87718b7 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -91,6 +91,12 @@ def discrimalign(seqlistA, seqlistB, assert aligner_mode in {'local', 'global'} assert gap_mode in {'affine', 'linear'} assert substitution_mode in {'general', 'symmetric', 'simple'} + if stepfunction is None and max_iter > 0: + raise ValueError( + 'stepfunction is required when max_iter > 0: pass a function ' + 'mapping the iteration number to a step size, e.g. ' + 'create_powerstep(1e-5) or create_constant_step(1e-5) from ' + 'src.optimization') if alphabet is None: alphabet = _warm_start_alphabet(baseline_aligner, initial_parameters) if alphabet is None: diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index be43ae9..0be8d7f 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -1,4 +1,5 @@ """Integration tests for src.discrimalign.discrimalign on small fixtures.""" +import sys import warnings from copy import deepcopy @@ -71,14 +72,25 @@ def test_rejects_unknown_modes(kwargs): discrimalign(A, B, y, stepfunction=create_constant_step(0.1), max_iter=1, **kwargs) -@pytest.mark.xfail(strict=True, raises=TypeError, reason=( - "BUG: stepfunction defaults to None and is called unconditionally, so " - "discrimalign() with default arguments crashes on the first iteration.")) -def test_default_stepfunction_runs(): +@pytest.mark.parametrize("max_iter", [1, 1000]) +def test_missing_stepfunction_raises_before_any_work(max_iter, monkeypatch): + # src/__init__.py rebinds src.discrimalign to the function, so the module + # is only reachable through sys.modules. + module = sys.modules["src.discrimalign"] + + def fail(*args, **kwargs): + raise AssertionError("aligned before validating stepfunction") + + monkeypatch.setattr(module, "_align_pairs", fail) _, A, B, y = _data() - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - discrimalign(A, B, y, max_iter=1) + with pytest.raises(ValueError, match="stepfunction is required"): + discrimalign(A, B, y, max_iter=max_iter) + + +def test_missing_stepfunction_is_fine_without_iterations(): + _, A, B, y = _data() + res = _run(A, B, y, "local", "affine", "simple", max_iter=0, stepfunction=None) + assert res["loglik_trajectory"] == [res["final_loglik"]] def test_single_class_labels_without_initial_parameters_raise(): From f2cc46faac66a37ccef32e671281b8d831f065ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 10:43:58 +0200 Subject: [PATCH 10/65] Move the small mixed test fixture into tests/helpers.py --- tests/helpers.py | 21 ++++++++++++++++++ tests/test_gradient_consistency.py | 35 +++++++----------------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 2106a92..3d04cd7 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -176,3 +176,24 @@ def perturbed(params, key, delta, index=None, symmetric=False): M[j, i] += delta new[key] = M return new + + +# Edge-case pairs mixed into every random fixture. +EDGE_PAIRS = [ + ("A", "A"), + ("A", "C"), + ("ACGT", "ACGT"), + ("AAAA", "CCCC"), + ("ACGTACGT", "ACG"), + ("G", "TTGTT"), +] + + +def small_fixture(seed, n=6, length=10): + """A few random pairs plus EDGE_PAIRS, with random labels.""" + rng = np.random.default_rng(seed) + seqsA, seqsB, labels = make_pairs(rng, n // 2, n - n // 2, length, sub_rate=0.2) + seqsA += [a for a, _ in EDGE_PAIRS] + seqsB += [b for _, b in EDGE_PAIRS] + labels = np.concatenate([labels, rng.integers(0, 2, size=len(EDGE_PAIRS))]) + return rng, seqsA, seqsB, labels diff --git a/tests/test_gradient_consistency.py b/tests/test_gradient_consistency.py index 1ef8fe5..101526a 100644 --- a/tests/test_gradient_consistency.py +++ b/tests/test_gradient_consistency.py @@ -15,31 +15,12 @@ from src.logit_link import logit_subgradient from tests.helpers import (DNA, GAP_MODES, MODES, SUBSTITUTION_MODES, align_all, + small_fixture, loglik_at, make_aligner, make_pairs, model_gradient, perturbed, random_params, subgradient_at) SEEDS = range(5) -# Edge-case pairs mixed into every random fixture. -EDGE_PAIRS = [ - ("A", "A"), - ("A", "C"), - ("ACGT", "ACGT"), - ("AAAA", "CCCC"), - ("ACGTACGT", "ACG"), - ("G", "TTGTT"), -] - - -def _fixture(seed, n=6, length=10): - rng = np.random.default_rng(seed) - seqsA, seqsB, labels = make_pairs(rng, n // 2, n - n // 2, length, sub_rate=0.2) - seqsA += [a for a, _ in EDGE_PAIRS] - seqsB += [b for _, b in EDGE_PAIRS] - labels = np.concatenate([labels, rng.integers(0, 2, size=len(EDGE_PAIRS))]) - return rng, seqsA, seqsB, labels - - def _counts_dot_params(sg, params): """Score implied by a single alignment's counts (weight 1).""" G = np.asarray(sg["Substitutions"]) @@ -61,7 +42,7 @@ def _counts_dot_params(sg, params): @pytest.mark.parametrize("gap_mode", GAP_MODES) @pytest.mark.parametrize("mode", MODES) def test_score_equals_counts_dot_parameters(mode, gap_mode, substitution_mode, seed): - rng, seqsA, seqsB, _ = _fixture(seed) + rng, seqsA, seqsB, _ = small_fixture(seed) params = random_params(rng, gap_mode, substitution_mode) for aln in align_all(seqsA, seqsB, make_aligner(mode, params)): sg = logit_subgradient([aln], [0.0], [1], 0.0, DNA) @@ -72,7 +53,7 @@ def test_score_equals_counts_dot_parameters(mode, gap_mode, substitution_mode, s @pytest.mark.parametrize("mode", MODES) def test_biopython_counts_match_subgradient_counts(mode, seed): """The initial estimate's features (aln.counts()) agree with logit_subgradient.""" - rng, seqsA, seqsB, _ = _fixture(seed) + rng, seqsA, seqsB, _ = small_fixture(seed) params = random_params(rng, "affine", "simple") for aln in align_all(seqsA, seqsB, make_aligner(mode, params)): sg = logit_subgradient([aln], [0.0], [1], 0.0, DNA) @@ -88,7 +69,7 @@ def test_biopython_counts_match_subgradient_counts(mode, seed): @pytest.mark.parametrize("seed", SEEDS) @pytest.mark.parametrize("mode", MODES) def test_biopython_counts_reproduce_score(mode, seed): - rng, seqsA, seqsB, _ = _fixture(seed) + rng, seqsA, seqsB, _ = small_fixture(seed) params = random_params(rng, "affine", "simple") for aln in align_all(seqsA, seqsB, make_aligner(mode, params)): c = aln.counts() @@ -111,7 +92,7 @@ def test_local_score_is_nonnegative_and_global_is_not_bounded(): @pytest.mark.parametrize("gap_mode", GAP_MODES) @pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) def test_local_score_dominates_global_score(gap_mode, substitution_mode, seed): - rng, seqsA, seqsB, _ = _fixture(seed) + rng, seqsA, seqsB, _ = small_fixture(seed) params = random_params(rng, gap_mode, substitution_mode) local = align_all(seqsA, seqsB, make_aligner("local", params)) glob = align_all(seqsA, seqsB, make_aligner("global", params)) @@ -151,7 +132,7 @@ def _directions(params, substitution_mode, alphabet=DNA): @pytest.mark.parametrize("gap_mode", GAP_MODES) @pytest.mark.parametrize("mode", MODES) def test_subgradient_matches_finite_differences(mode, gap_mode, substitution_mode, seed): - rng, seqsA, seqsB, labels = _fixture(seed) + rng, seqsA, seqsB, labels = small_fixture(seed) params = random_params(rng, gap_mode, substitution_mode) sg = subgradient_at(seqsA, seqsB, labels, mode, params, DNA) grad = model_gradient(sg, gap_mode, substitution_mode) @@ -166,7 +147,7 @@ def test_subgradient_matches_finite_differences(mode, gap_mode, substitution_mod @pytest.mark.parametrize("mode", MODES) def test_alpha_derivative_is_sum_of_residuals(mode): - rng, seqsA, seqsB, labels = _fixture(11) + rng, seqsA, seqsB, labels = small_fixture(11) params = random_params(rng, "affine", "simple") h = 1e-6 up = loglik_at(seqsA, seqsB, labels, mode, dict(params, alpha=params["alpha"] + h)) @@ -181,7 +162,7 @@ def test_alpha_derivative_is_sum_of_residuals(mode): @pytest.mark.parametrize("mode", MODES) def test_score_is_convex_in_parameters(mode, seed): """Viterbi score is a max of linear functions, hence convex along any line.""" - rng, seqsA, seqsB, _ = _fixture(seed) + rng, seqsA, seqsB, _ = small_fixture(seed) p0 = random_params(rng, "affine", "general") p1 = random_params(rng, "affine", "general") From 9b59f487f2f129de39ab691d3c21d004f6359f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Tue, 29 Sep 2026 10:43:58 +0200 Subject: [PATCH 11/65] Add nwgrad dependency and parameter/gradient conversion layer --- pyproject.toml | 1 + src/nwgrad_params.py | 77 ++++++++++++ tests/test_nwgrad_params.py | 240 ++++++++++++++++++++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 src/nwgrad_params.py create mode 100644 tests/test_nwgrad_params.py diff --git a/pyproject.toml b/pyproject.toml index c96b774..763f873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "jupyterlab>=4.2.0", "matplotlib>=3.9.2", "mirbench", + "nwgrad>=0.4.0", "numpy>=1.26.4", "pandas>=2.2.2", "scikit-learn>=1.4.2", diff --git a/src/nwgrad_params.py b/src/nwgrad_params.py new file mode 100644 index 0000000..3aa0bad --- /dev/null +++ b/src/nwgrad_params.py @@ -0,0 +1,77 @@ +""" +Conversion between DiscrimAlign's alignment parameters and nwgrad's. + +DiscrimAlign keeps parameters in Biopython's convention: gap values are +negative scores, and a gap of length k scores open + (k-1)*extend. +nwgrad takes positive penalties, a gap of length k costs open + k*extend, +and gaps in sequence A (*_a) and sequence B (*_b) are priced separately. +DiscrimAlign's model has one gap cost for both sequences. +""" +import numpy as np +import nwgrad +from Bio.Align import substitution_matrices + + +def substitution_data(params, substitution_mode, alphabet): + """ + The substitution matrix as a float64 array, and its alphabet. + + In simple mode it is built from match_score and mismatch_score over + alphabet. Otherwise the matrix's own alphabet is used, if it has one. + """ + if substitution_mode == 'simple': + n = len(alphabet) + data = np.full((n, n), float(params['mismatch_score'])) + data[np.diag_indices(n)] = params['match_score'] + return data, alphabet + matrix = params['substitution_matrix'] + matrix_alphabet = getattr(matrix, 'alphabet', None) + if matrix_alphabet is not None: + alphabet = ''.join(matrix_alphabet) + return np.ascontiguousarray(matrix, dtype=np.float64), alphabet + + +def gap_penalties(params, gap_mode): + """(gap_open, gap_extend) as nwgrad penalties, for either sequence.""" + if gap_mode == 'affine': + return (params['extend_gap_score'] - params['open_gap_score'], + -params['extend_gap_score']) + return 0.0, -params['gap_score'] + + +def to_nwgrad(params, gap_mode, substitution_mode, alphabet): + """ + nwgrad.AlignParams equivalent to a DiscrimAlign parameter dict. + + alphabet is used in simple mode, and in the other modes when the + substitution matrix does not carry its own alphabet. + """ + data, alphabet = substitution_data(params, substitution_mode, alphabet) + gap_open, gap_extend = gap_penalties(params, gap_mode) + return nwgrad.AlignParams(nwgrad.SubstMatrix(data, alphabet=alphabet), + gap_open_a=gap_open, gap_extend_a=gap_extend, + gap_open_b=gap_open, gap_extend_b=gap_extend) + + +def grad_to_raw(grad): + """ + An nwgrad gradient in logit_subgradient's output format. + + grad is an nwgrad.AlignParams gradient, or a dict shaped like its + to_dict(). nwgrad reports gap gradients as minus the counts: gap_open is + minus the number of gaps and gap_extend minus the number of gap columns. + Biopython counts the first column of each gap as its open, and the rest + as extends. In linear mode nwgrad only reports gap columns, so all of + them land in 'Gap extends' and only the sum of the two is meaningful. + The conversion is linear, so weighted sums of gradients may be + converted after summing. + """ + if hasattr(grad, 'to_dict'): + grad = grad.to_dict() + gap_open = grad['gap_open_a'] + grad['gap_open_b'] + gap_extend = grad['gap_extend_a'] + grad['gap_extend_b'] + substitutions = substitution_matrices.Array(alphabet=grad['alphabet'], + data=np.array(grad['matrix'], dtype=float)) + return {'Substitutions': substitutions, + 'Gap opens': -gap_open, + 'Gap extends': gap_open - gap_extend} diff --git a/tests/test_nwgrad_params.py b/tests/test_nwgrad_params.py new file mode 100644 index 0000000..3544e9d --- /dev/null +++ b/tests/test_nwgrad_params.py @@ -0,0 +1,240 @@ +""" +Tests for src.nwgrad_params: parameters converted to nwgrad must reproduce +Biopython's scores, and nwgrad's gradients converted back must reproduce +logit_subgradient's counts. + +The double-precision SeqPairDouble with pointer traceback is used +throughout, so scores are exact up to summation order and paths are optimal. +""" +from copy import deepcopy + +import nwgrad +import numpy as np +import pytest +from Bio.Align import substitution_matrices + +from src.logit_link import logit_subgradient +from src.nwgrad_params import (gap_penalties, grad_to_raw, substitution_data, + to_nwgrad) +from tests.helpers import (DNA, GAP_MODES, MODES, SUBSTITUTION_MODES, align_all, + make_aligner, model_gradient, random_params, small_fixture) + +SEEDS = range(5) + + +def _nwgrad_pair(a, b, mode, gap_mode, substitution_mode, params, alphabet=DNA): + pair = nwgrad.SeqPairDouble(a, b, to_nwgrad(params, gap_mode, substitution_mode, alphabet), + gap_model=gap_mode, mode=mode, traceback="pointers") + pair.score_and_grad() + return pair + + +def _simple(match=2.0, mismatch=-1.0, open_=-3.0, extend=-1.0): + return {"match_score": match, "mismatch_score": mismatch, + "open_gap_score": open_, "extend_gap_score": extend} + + +# --- forward: parameter conversion ------------------------------------------ + +def test_affine_gap_penalties(): + assert gap_penalties({"open_gap_score": -10.0, "extend_gap_score": -0.5}, "affine") == (9.5, 0.5) + + +def test_linear_gap_penalties(): + assert gap_penalties({"gap_score": -6.0}, "linear") == (0.0, 6.0) + + +def test_extend_below_open_gives_negative_surcharge(): + go, ge = gap_penalties({"open_gap_score": -1.0, "extend_gap_score": -10.0}, "affine") + assert (go, ge) == (-9.0, 10.0) + + +def test_simple_mode_matrix(): + data, alphabet = substitution_data({"match_score": 5, "mismatch_score": -4}, "simple", "ACG") + assert alphabet == "ACG" + np.testing.assert_array_equal(data, 9 * np.eye(3) - 4) + assert data.dtype == np.float64 + + +def test_matrix_keeps_its_own_alphabet(): + M = substitution_matrices.Array(alphabet="TGCA", data=np.arange(16.0).reshape(4, 4)) + data, alphabet = substitution_data({"substitution_matrix": M}, "general", "ACGT") + assert alphabet == "TGCA" + np.testing.assert_array_equal(data, np.arange(16.0).reshape(4, 4)) + + +def test_plain_array_matrix_uses_given_alphabet(): + data, alphabet = substitution_data({"substitution_matrix": np.eye(4)}, "general", "ACGT") + assert alphabet == "ACGT" + np.testing.assert_array_equal(data, np.eye(4)) + + +@pytest.mark.parametrize("gap_mode", GAP_MODES) +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +def test_to_nwgrad_fields(gap_mode, substitution_mode): + params = random_params(np.random.default_rng(0), gap_mode, substitution_mode) + nw = to_nwgrad(params, gap_mode, substitution_mode, DNA).to_dict() + go, ge = gap_penalties(params, gap_mode) + assert nw["gap_open_a"] == nw["gap_open_b"] == go + assert nw["gap_extend_a"] == nw["gap_extend_b"] == ge + assert nw["alphabet"] == DNA + expected, _ = substitution_data(params, substitution_mode, DNA) + np.testing.assert_array_equal(nw["matrix"], expected) + + +def test_to_nwgrad_does_not_mutate_params(): + params = random_params(np.random.default_rng(1), "affine", "general") + before = deepcopy(params) + to_nwgrad(params, "affine", "general", DNA) + np.testing.assert_array_equal(np.asarray(params["substitution_matrix"]), + np.asarray(before["substitution_matrix"])) + assert params["open_gap_score"] == before["open_gap_score"] + + +@pytest.mark.parametrize("a, b, mode, expected", [ + ("ACGT", "ACGT", "global", 8.0), + ("AGGGC", "AC", "global", 2 + 2 - 3 - 1 - 1), # one gap of length 3 + ("AC", "AGGGC", "global", 2 + 2 - 3 - 1 - 1), # same gap, in the other sequence + ("ACGT", "AGT", "global", 2 + 2 + 2 - 3), # one gap of length 1 + ("TTACGTT", "GGACGGG", "local", 6.0), # ACG + ("AAAA", "CCCC", "local", 0.0), # empty local alignment +]) +def test_hand_computed_scores(a, b, mode, expected): + pair = _nwgrad_pair(a, b, mode, "affine", "simple", _simple()) + assert pair.score == pytest.approx(expected) + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +@pytest.mark.parametrize("gap_mode", GAP_MODES) +@pytest.mark.parametrize("mode", MODES) +def test_scores_match_biopython(mode, gap_mode, substitution_mode, seed): + rng, seqsA, seqsB, _ = small_fixture(seed) + params = random_params(rng, gap_mode, substitution_mode) + bio = [aln.score for aln in align_all(seqsA, seqsB, make_aligner(mode, params))] + nw = [_nwgrad_pair(a, b, mode, gap_mode, substitution_mode, params).score + for a, b in zip(seqsA, seqsB)] + assert nw == pytest.approx(bio, rel=1e-12, abs=1e-12) + + +@pytest.mark.parametrize("a, b", [("A", "C"), ("AT", "CG"), ("GAT", "GCT"), ("AB", "CD")]) +def test_scores_match_biopython_when_gaps_beat_mismatches(a, b): + """Adjacent gaps in different sequences, and extend below open.""" + for params in (_simple(1.0, -100.0, -1.0, -0.5), _simple(1.0, -100.0, -1.0, -10.0)): + bio = align_all([a], [b], make_aligner("global", params))[0].score + assert _nwgrad_pair(a, b, "global", "affine", "simple", params, "ABCDGT").score == \ + pytest.approx(bio) + + +# --- backward: gradient conversion ------------------------------------------ + +def _raw_counts(aln, alphabet=DNA): + return logit_subgradient([aln], [0.0], [1], 0.0, alphabet) + + +def test_grad_to_raw_gap_of_length_three(): + raw = grad_to_raw(_nwgrad_pair("AGGGC", "AC", "global", "affine", "simple", _simple()).grad) + assert raw["Gap opens"] == 1 + assert raw["Gap extends"] == 2 + assert raw["Substitutions"]["A", "A"] == 1 + assert raw["Substitutions"]["C", "C"] == 1 + assert np.asarray(raw["Substitutions"]).sum() == 2 + + +def test_grad_to_raw_alternating_gaps_are_all_opens(): + params = _simple(1.0, -100.0, -1.0, -10.0) + raw = grad_to_raw(_nwgrad_pair("AB", "CD", "global", "affine", "simple", params, "ABCD").grad) + assert raw["Gap opens"] == 4 + assert raw["Gap extends"] == 0 + + +def test_grad_to_raw_substitution_orientation(): + """Rows are residues of sequence A, columns residues of sequence B.""" + params = {"substitution_matrix": substitution_matrices.Array( + alphabet=DNA, data=np.where(np.eye(4) > 0, 2.0, -1.0)), + "open_gap_score": -10.0, "extend_gap_score": -10.0} + raw = grad_to_raw(_nwgrad_pair("AG", "CT", "global", "affine", "general", params).grad) + assert raw["Substitutions"]["A", "C"] == 1 + assert raw["Substitutions"]["C", "A"] == 0 + assert raw["Substitutions"]["G", "T"] == 1 + + +def test_grad_to_raw_accepts_dict_and_object(): + grad = _nwgrad_pair("ACGGT", "AT", "global", "affine", "simple", _simple()).grad + a, b = grad_to_raw(grad), grad_to_raw(grad.to_dict()) + np.testing.assert_array_equal(np.asarray(a["Substitutions"]), np.asarray(b["Substitutions"])) + assert (a["Gap opens"], a["Gap extends"]) == (b["Gap opens"], b["Gap extends"]) + + +def test_grad_to_raw_is_linear(): + """Weighted sums of gradients may be converted after summing.""" + params = _simple() + grads = [_nwgrad_pair(a, b, "global", "affine", "simple", params).grad.to_dict() + for a, b in [("AGGGC", "AC"), ("ACGT", "AGT"), ("GATTACA", "GACA")]] + weights = [0.3, -1.2, 0.7] + summed = {k: sum(w * g[k] for w, g in zip(weights, grads)) + for k in ("matrix", "gap_open_a", "gap_extend_a", "gap_open_b", "gap_extend_b")} + summed["alphabet"] = grads[0]["alphabet"] + raw_sum = grad_to_raw(summed) + parts = [grad_to_raw(g) for g in grads] + np.testing.assert_allclose(np.asarray(raw_sum["Substitutions"]), + sum(w * np.asarray(p["Substitutions"]) for w, p in zip(weights, parts))) + for key in ("Gap opens", "Gap extends"): + assert raw_sum[key] == pytest.approx(sum(w * p[key] for w, p in zip(weights, parts))) + + +def test_empty_local_alignment_gradient_is_zero(): + raw = grad_to_raw(_nwgrad_pair("AAAA", "CCCC", "local", "affine", "simple", _simple()).grad) + assert np.all(np.asarray(raw["Substitutions"]) == 0) + assert raw["Gap opens"] == 0 + assert raw["Gap extends"] == 0 + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +@pytest.mark.parametrize("gap_mode", GAP_MODES) +@pytest.mark.parametrize("mode", MODES) +def test_model_gradient_matches_logit_subgradient(mode, gap_mode, substitution_mode, seed): + """ + Per pair, the gradient in the model's own parameters agrees. + + Raw counts can legitimately differ where the model has ties: in simple + mode an A/C and an A/G mismatch score the same, and in linear mode so + do different gap placements. Only the model's parameters must agree. + """ + rng, seqsA, seqsB, _ = small_fixture(seed) + params = random_params(rng, gap_mode, substitution_mode) + alns = align_all(seqsA, seqsB, make_aligner(mode, params)) + for a, b, aln in zip(seqsA, seqsB, alns): + nw = model_gradient(grad_to_raw(_nwgrad_pair(a, b, mode, gap_mode, substitution_mode, + params).grad), gap_mode, substitution_mode) + bio = model_gradient(_raw_counts(aln), gap_mode, substitution_mode) + assert nw.keys() == bio.keys() + for key in nw: + np.testing.assert_array_equal(np.asarray(nw[key]), np.asarray(bio[key]), + err_msg=f"{key}: {a} / {b}") + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("mode", MODES) +def test_raw_counts_match_logit_subgradient_without_ties(mode, seed): + """General matrix and affine gaps at continuous parameters: the path is unique.""" + rng, seqsA, seqsB, _ = small_fixture(seed) + params = random_params(rng, "affine", "general") + alns = align_all(seqsA, seqsB, make_aligner(mode, params)) + for a, b, aln in zip(seqsA, seqsB, alns): + nw = grad_to_raw(_nwgrad_pair(a, b, mode, "affine", "general", params).grad) + bio = _raw_counts(aln) + np.testing.assert_array_equal(np.asarray(nw["Substitutions"]), + np.asarray(bio["Substitutions"]), err_msg=f"{a} / {b}") + assert (nw["Gap opens"], nw["Gap extends"]) == (bio["Gap opens"], bio["Gap extends"]) + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("mode", MODES) +def test_linear_mode_reports_all_gap_columns_as_extends(mode, seed): + rng, seqsA, seqsB, _ = small_fixture(seed) + params = random_params(rng, "linear", "simple") + for a, b in zip(seqsA, seqsB): + nw = grad_to_raw(_nwgrad_pair(a, b, mode, "linear", "simple", params).grad) + assert nw["Gap opens"] == 0 From f66dbe2f2fec6e60603c4ed5a8398c8de2ada9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Wed, 30 Sep 2026 07:45:46 +0200 Subject: [PATCH 12/65] Untrack committed bytecode and add .gitignore --- .gitignore | 5 +++++ tests/__pycache__/__init__.cpython-314.pyc | Bin 146 -> 0 bytes tests/__pycache__/helpers.cpython-314.pyc | Bin 10181 -> 0 bytes ...st_discrimalign.cpython-314-pytest-9.0.3.pyc | Bin 69718 -> 0 bytes ...imalign_helpers.cpython-314-pytest-9.0.3.pyc | Bin 27575 -> 0 bytes ...ent_consistency.cpython-314-pytest-9.0.3.pyc | Bin 35116 -> 0 bytes .../test_gradient_consistency.cpython-314.pyc | Bin 15856 -> 0 bytes ...test_logit_link.cpython-314-pytest-9.0.3.pyc | Bin 65759 -> 0 bytes ...st_optimization.cpython-314-pytest-9.0.3.pyc | Bin 62967 -> 0 bytes 9 files changed, 5 insertions(+) create mode 100644 .gitignore delete mode 100644 tests/__pycache__/__init__.cpython-314.pyc delete mode 100644 tests/__pycache__/helpers.cpython-314.pyc delete mode 100644 tests/__pycache__/test_discrimalign.cpython-314-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_discrimalign_helpers.cpython-314-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_gradient_consistency.cpython-314-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_gradient_consistency.cpython-314.pyc delete mode 100644 tests/__pycache__/test_logit_link.cpython-314-pytest-9.0.3.pyc delete mode 100644 tests/__pycache__/test_optimization.cpython-314-pytest-9.0.3.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfcfab8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.venv/ diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 012e2eff3473006bcf1fd09df64c023082e89594..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 146 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 diff --git a/tests/__pycache__/test_discrimalign.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_discrimalign.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 812130a8c20eeb607b7ea0df7ac2d916d471af59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69718 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 diff --git a/tests/__pycache__/test_discrimalign_helpers.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_discrimalign_helpers.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index d609b6c4161f8b1e48f46b8b4e02ac156c785c51..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 27575 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 diff --git a/tests/__pycache__/test_gradient_consistency.cpython-314.pyc b/tests/__pycache__/test_gradient_consistency.cpython-314.pyc deleted file mode 100644 index 51374051be4a5a29576f33281cc42b689465a623..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15856 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 diff --git a/tests/__pycache__/test_logit_link.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/test_logit_link.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index 90af6884f6ce3d2de49fa8469f1a235962a6cd62..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65759 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 From 5e499ff15556a1461a8c5c56445f18f478e03e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Wed, 30 Sep 2026 07:47:25 +0200 Subject: [PATCH 13/65] Add GitHub Actions workflow running the test suite on Linux --- .github/workflows/tests.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..9cd9068 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,16 @@ +name: tests + +on: + push: + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.12" + - run: uv sync + - run: uv run pytest From 0ec62aa4c74568f10e626f5c3421427fe4e22a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Wed, 30 Sep 2026 07:48:28 +0200 Subject: [PATCH 14/65] Bump setup-uv to v10 (Node 24) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9cd9068..60a395e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@v10 with: python-version: "3.12" - run: uv sync From 44bdc7a778fba073e9cab52b5c12b8c9f94b74f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Wed, 30 Sep 2026 07:49:03 +0200 Subject: [PATCH 15/65] Pin setup-uv to v10.2.0; there is no floating v10 tag --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60a395e..e326922 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v10 + - uses: astral-sh/setup-uv@v10.2.0 with: python-version: "3.12" - run: uv sync From 94f183c6a01742ecddf877fa27ea813c67f045a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Wed, 30 Sep 2026 08:05:57 +0200 Subject: [PATCH 16/65] Route discrimalign() iterations through a backend engine and add the nwgrad backend --- src/discrimalign.py | 379 ++++++++---------------------------------- src/nwgrad_backend.py | 62 +++++++ 2 files changed, 133 insertions(+), 308 deletions(-) create mode 100644 src/nwgrad_backend.py diff --git a/src/discrimalign.py b/src/discrimalign.py index 87718b7..6cb8fb4 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -72,6 +72,48 @@ def _warm_start_alphabet(baseline_aligner=None, initial_parameters=None): return None +def _configure_aligner(aligner, params, gap_mode, substitution_mode): + if gap_mode == 'affine': + aligner.open_gap_score = params['open_gap_score'] + aligner.extend_gap_score = params['extend_gap_score'] + elif gap_mode == 'linear': + aligner.gap_score = params['gap_score'] + + if substitution_mode == 'simple': + aligner.match_score = params['match_score'] + aligner.mismatch_score = params['mismatch_score'] + else: + aligner.substitution_matrix = params['substitution_matrix'] + + +class _BiopythonEngine: + """ + Scores and subgradients from Biopython alignments: set_params(), then + scores(), then raw_subgradient() for the alignments just computed. + """ + + def __init__(self, seqlistA, seqlistB, aligner, gap_mode, substitution_mode, + alphabet, num_threads): + self.seqlistA = seqlistA + self.seqlistB = seqlistB + self.aligner = aligner + self.gap_mode = gap_mode + self.substitution_mode = substitution_mode + self.alphabet = alphabet + self.num_threads = num_threads + self.alignments = None + + def set_params(self, params): + _configure_aligner(self.aligner, params, self.gap_mode, self.substitution_mode) + + def scores(self): + self.alignments = _align_pairs(self.seqlistA, self.seqlistB, self.aligner, self.num_threads) + return [aln.score for aln in self.alignments] + + def raw_subgradient(self, logit_scores, labels, alpha): + return logit_subgradient(self.alignments, logit_scores, labels, alpha, self.alphabet) + + def discrimalign(seqlistA, seqlistB, labels, baseline_aligner=None, @@ -86,8 +128,16 @@ def discrimalign(seqlistA, seqlistB, subgradient_scale=1.0, initial_parameters=None, return_alignments=True, - verbose=False): + verbose=False, + backend='biopython'): + """ + backend selects where alignment scores and subgradients come from in + the iterations: 'biopython' (PairwiseAligner) or 'nwgrad'. The initial + estimate, the returned aligner and the returned alignments always use + Biopython. + """ # TODO: Implement tol and additional stepfunctions. + assert backend in {'biopython', 'nwgrad'} assert aligner_mode in {'local', 'global'} assert gap_mode in {'affine', 'linear'} assert substitution_mode in {'general', 'symmetric', 'simple'} @@ -158,17 +208,16 @@ def add_noise(shape, niter): if verbose: print('Initial parameters:') print(updated_parameters) - if gap_mode == 'affine': - aligner.open_gap_score = updated_parameters['open_gap_score'] - aligner.extend_gap_score = updated_parameters['extend_gap_score'] - else: - aligner.gap_score = updated_parameters['gap_score'] + _configure_aligner(aligner, updated_parameters, gap_mode, substitution_mode) - if substitution_mode == 'simple': - aligner.match_score = updated_parameters['match_score'] - aligner.mismatch_score = updated_parameters['mismatch_score'] + # The baseline aligner's mode, when given, takes precedence over aligner_mode. + if backend == 'nwgrad': + from .nwgrad_backend import NwgradEngine + engine = NwgradEngine(seqlistA, seqlistB, aligner.mode, gap_mode, + substitution_mode, alphabet, num_threads) else: - aligner.substitution_matrix = updated_parameters['substitution_matrix'] + engine = _BiopythonEngine(seqlistA, seqlistB, aligner, gap_mode, + substitution_mode, alphabet, num_threads) # Subgradient refinement loglik_trajectory = [] @@ -178,22 +227,9 @@ def add_noise(shape, niter): for iternb in range(max_iter): if verbose: print('Start of iteration', iternb) - # Set new aligner parameters - if gap_mode == 'affine': - aligner.open_gap_score = updated_parameters['open_gap_score'] - aligner.extend_gap_score = updated_parameters['extend_gap_score'] - elif gap_mode == 'linear': - aligner.gap_score = updated_parameters['gap_score'] - - if substitution_mode == 'simple': - aligner.match_score = updated_parameters['match_score'] - aligner.mismatch_score = updated_parameters['mismatch_score'] - else: - aligner.substitution_matrix = updated_parameters['substitution_matrix'] - # Realign with the new parameters - alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) - alignment_scores = [aln.score for aln in alnlist] + engine.set_params(updated_parameters) + alignment_scores = engine.scores() logit_scores = logit_partial_scores(alignment_scores, updated_parameters['alpha']) new_logL = logit_logL(logit_scores, labels) @@ -240,9 +276,7 @@ def alpha_fsec(alpha): updated_parameters['alpha'] = new_alpha # Make a subgradient step - subgradient = logit_subgradient(alnlist, logit_scores, - labels, new_alpha, - alphabet) + subgradient = engine.raw_subgradient(logit_scores, labels, new_alpha) if subgradient_scale != 1.0: subgradient['Gap opens'] *= subgradient_scale subgradient['Gap extends'] *= subgradient_scale @@ -325,287 +359,13 @@ def alpha_fsec(alpha): results['substitution_matrix'] = updated_parameters['substitution_matrix'] # Realign with the new parameters - alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) - alignment_scores = [aln.score for aln in alnlist] - logit_scores = logit_partial_scores(alignment_scores, - updated_parameters['alpha']) - new_logL = logit_logL(logit_scores, labels) -## EL = 0 -## VL = 0 -## for ls in logit_scores: -## if 1e-30 < ls < 1-1e-30: -## EL += ls*np.log(ls) + (1-ls)*np.log(1-ls) -## VL += ls*(1-ls)*(np.log(ls)**2 + np.log(1-ls)**2) -## SDL = np.sqrt(VL) -## loglik_expectation.append(EL) -## loglik_sd.append(SDL) - loglik_trajectory.append(new_logL) - results['loglik_trajectory'] = loglik_trajectory - results['subgradient_l2_trajectory'] = subgradient_l2_trajectory - results['final_loglik'] = new_logL - results['aligner'] = aligner - if return_alignments: - results['alignments'] = alnlist - results['alignment_logit_scores'] = logit_scores - results['alpha'] = updated_parameters['alpha'] - # results['loglik_expectation_trajectory'] = loglik_expectation - # results['loglik_sd_trajectory'] = loglik_sd - return results - -def discrimalign_nwgrad(seqlistA, seqlistB, - labels, - baseline_params=None, - aligner_mode='local', - gap_mode='affine', - substitution_mode='symmetric', - alphabet=None, - stochastic_factor=None, - stepfunction=None, - max_iter=1000, tol=1e-3, - num_threads=1, - subgradient_scale=1.0, - initial_parameters=None, - return_alignments=True, - verbose=False): - import nwgrad - assert aligner_mode in {'local', 'global'} - assert gap_mode in {'affine', 'linear'} - assert substitution_mode in {'general', 'symmetric', 'simple'} - if alphabet is None: - charsetA = set(char for seq in seqlistA for char in seq) - charsetB = set(char for seq in seqlistB for char in seq) - alphabet = charsetA | charsetB - alphabet = ''.join(sorted(alphabet)) - - if verbose: - print('Alphabet:') - print(alphabet) - - # Stochastic trick function - if stochastic_factor is None: - def add_noise(shape, niter): - if shape == 1: - return 0. - else: - return np.zeros(shape) - # Note: For consistency with numpy, returning a number should - # happen when shape == None, shape 1 should return an array. - # This would require modifying the code elsewhere - else: - def add_noise(shape, niter): - if shape == 1: - return rd.normal(scale=stochastic_factor/(niter+1)) - else: - return rd.normal(size=shape, scale=stochastic_factor/(niter+1)) - - # Initial alignments - if baseline_params is not None: - baseline_params = deepcopy(baseline_params) - else: - subsmat = np.eye(len(alphabet))*(5+4) - 4 - subsmat = nwgrad.SubstMatrix(subsmat, alphabet=alphabet) - if gap_mode == 'affine': - gapopen = -10 - gapextend = -0.5 - elif gap_mode == 'linear': - gapopen = -6 - gapextend = -6 - params = nwgrad.AlignParams( - subsmat, - gap_open_a=gapopen, gap_extend_a=gapextend, - gap_open_b=gapopen, gap_extend_b=gapextend, - ) - seqpairs = [ - nwgrad.SeqPair(a, b, params, - gap_model=gap_mode, mode=aligner_mode) - for a, b in zip(seqlistA, seqlistB) - ] - batch = nwgrad.SeqPairBatch(n_threads=8) - for sp in pairs: - batch.add(sp) - total_log_z = batch.score() - alignment_scores = [aln.score for aln in alnlist] - - # Initial logistic estimation, unless caller provides fitted parameters for a warm start. - if initial_parameters is not None: - updated_parameters = deepcopy(initial_parameters) - else: - updated_parameters = get_initial_estimate(alnlist, labels, - substitution_mode=substitution_mode, - gap_mode=gap_mode, - alphabet=alphabet) - if verbose: - print('Initial parameters:') - print(updated_parameters) - if gap_mode == 'affine': - aligner.open_gap_score = updated_parameters['open_gap_score'] - aligner.extend_gap_score = updated_parameters['extend_gap_score'] - else: - aligner.gap_score = updated_parameters['gap_score'] - - if substitution_mode == 'simple': - aligner.match_score = updated_parameters['match_score'] - aligner.mismatch_score = updated_parameters['mismatch_score'] - else: - aligner.substitution_matrix = updated_parameters['substitution_matrix'] - - # Subgradient refinement - loglik_trajectory = [] - subgradient_l2_trajectory = [] - loglik_expectation = [] - loglik_sd = [] - for iternb in range(max_iter): - if verbose: - print('Start of iteration', iternb) - # Set new aligner parameters - if gap_mode == 'affine': - aligner.open_gap_score = updated_parameters['open_gap_score'] - aligner.extend_gap_score = updated_parameters['extend_gap_score'] - elif gap_mode == 'linear': - aligner.gap_score = updated_parameters['gap_score'] - - if substitution_mode == 'simple': - aligner.match_score = updated_parameters['match_score'] - aligner.mismatch_score = updated_parameters['mismatch_score'] - else: - aligner.substitution_matrix = updated_parameters['substitution_matrix'] - - # Realign with the new parameters - alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) - alignment_scores = [aln.score for aln in alnlist] - logit_scores = logit_partial_scores(alignment_scores, - updated_parameters['alpha']) - new_logL = logit_logL(logit_scores, labels) - loglik_trajectory.append(new_logL) - if verbose: - print("Current alpha:", updated_parameters['alpha']) - print('Current logL:', new_logL) -## EL = 0 -## VL = 0 -## for ls in logit_scores: -## if 1e-30 < ls < 1-1e-30: -## EL += ls*np.log(ls) + (1-ls)*np.log(1-ls) -## VL += ls*(1-ls)*(np.log(ls)**2 + np.log(1-ls)**2) -## SDL = np.sqrt(VL) -## loglik_expectation.append(EL) -## loglik_sd.append(SDL) - - # Optimize the logistic intercept (alpha) - def alpha_target(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return -logit_logL(logit_scores, labels) - - def alpha_fprime(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return -np.sum(labels - logit_scores) - - def alpha_fsec(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return np.sum(logit_scores*(1 - logit_scores)) - - new_alpha = minimize(alpha_target, - updated_parameters['alpha'], - jac=alpha_fprime, - # hess=alpha_fsec, - # method='Newton-CG' - )['x'][0] - - logit_scores = logit_partial_scores(alignment_scores, new_alpha) - if verbose: - new_logL = logit_logL(logit_scores, labels) - print("Updated alpha:", new_alpha) - print('Updated logL:', new_logL) - - updated_parameters['alpha'] = new_alpha - - # Make a subgradient step - subgradient = logit_subgradient(alnlist, logit_scores, - labels, new_alpha, - alphabet) - if subgradient_scale != 1.0: - subgradient['Gap opens'] *= subgradient_scale - subgradient['Gap extends'] *= subgradient_scale - subgradient['Substitutions'] *= subgradient_scale - - stepsize = stepfunction(iternb) - if verbose: - print('New subgradient:') - print(subgradient) - print('Stepsize:', stepsize) - - subgradient_square_norm = 0 - if gap_mode == 'affine': - updated_parameters['open_gap_score'] += stepsize*subgradient['Gap opens'] + add_noise(1, iternb) - updated_parameters['extend_gap_score'] += stepsize*subgradient['Gap extends'] + add_noise(1, iternb) - subgradient_square_norm += subgradient['Gap opens']**2 - subgradient_square_norm += subgradient['Gap extends']**2 - if verbose: - print('Gap open step:', stepsize*subgradient['Gap opens']) - print('Gap extend step:', stepsize*subgradient['Gap extends']) - elif gap_mode == 'linear': - gapnb = subgradient['Gap opens'] + subgradient['Gap extends'] - updated_parameters['gap_score'] += stepsize*gapnb + add_noise(1, iternb) - subgradient_square_norm += gapnb**2 - if verbose: - print('Linear gap step:', stepsize*gapnb) - - if substitution_mode == 'simple': - match_subgradient = np.sum(np.diag(subgradient['Substitutions'])) - mismatch_subgradient = np.sum(subgradient['Substitutions']) - match_subgradient - updated_parameters['match_score'] += stepsize*match_subgradient + add_noise(1, iternb) - updated_parameters['mismatch_score'] += stepsize*mismatch_subgradient + add_noise(1, iternb) - subgradient_square_norm += match_subgradient**2 - subgradient_square_norm += mismatch_subgradient**2 - if verbose: - print('Match step:', stepsize*match_subgradient) - print('Mismatch step:', stepsize*mismatch_subgradient) - elif substitution_mode == 'symmetric': - subsM = subgradient['Substitutions'] - subsM = subsM + add_noise(subsM.shape, iternb) - # Summing mismatch counts to keep the matrix symmetric - # with off-diagonal elements corresponding to mismatches, - # subtracting the diagonal to avoid counting matches twice - subsM = subsM + subsM.T - np.diag(np.diag(subsM)) - # subsM = subsM + subsM.T - updated_parameters['substitution_matrix'] += stepsize*subsM - subgradient_square_norm += np.sum(subsM**2) - else: - subsM = subgradient['Substitutions'] - subsM = subsM + add_noise(subsM.shape, iternb) - updated_parameters['substitution_matrix'] += stepsize*subsM - subgradient_square_norm += np.sum(subsM**2) - subgradient_l2_trajectory.append(np.sqrt(subgradient_square_norm)) - if verbose: - print('New parameters:') - print(updated_parameters) - print('Subgradient norm:', subgradient_l2_trajectory[-1]) - if verbose: - print('End of iteration', iternb) - print() - - # Set final parameters - results = {} - if gap_mode == 'affine': - aligner.open_gap_score = updated_parameters['open_gap_score'] - aligner.extend_gap_score = updated_parameters['extend_gap_score'] - results['open_gap_score'] = updated_parameters['open_gap_score'] - results['extend_gap_score'] = updated_parameters['extend_gap_score'] - elif gap_mode == 'linear': - aligner.gap_score = updated_parameters['gap_score'] - results['gap_score'] = updated_parameters['gap_score'] - - if substitution_mode == 'simple': - aligner.match_score = updated_parameters['match_score'] - aligner.mismatch_score = updated_parameters['mismatch_score'] - results['match_score'] = updated_parameters['match_score'] - results['mismatch_score'] = updated_parameters['mismatch_score'] + engine.set_params(updated_parameters) + alignment_scores = engine.scores() + if backend == 'nwgrad': + alnlist = (_align_pairs(seqlistA, seqlistB, aligner, num_threads) + if return_alignments else None) else: - aligner.substitution_matrix = updated_parameters['substitution_matrix'] - results['substitution_matrix'] = updated_parameters['substitution_matrix'] - - # Realign with the new parameters - alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) - alignment_scores = [aln.score for aln in alnlist] + alnlist = engine.alignments logit_scores = logit_partial_scores(alignment_scores, updated_parameters['alpha']) new_logL = logit_logL(logit_scores, labels) @@ -631,3 +391,6 @@ def alpha_fsec(alpha): # results['loglik_sd_trajectory'] = loglik_sd return results +def discrimalign_nwgrad(*args, **kwargs): + """discrimalign() with backend='nwgrad'.""" + return discrimalign(*args, backend='nwgrad', **kwargs) diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py new file mode 100644 index 0000000..5be4665 --- /dev/null +++ b/src/nwgrad_backend.py @@ -0,0 +1,62 @@ +""" +nwgrad backend for discrimalign(): alignment scores and the log-likelihood +subgradient from one nwgrad batch, built on first use and reused across +iterations. +""" +import numpy as np +import nwgrad + +from .nwgrad_params import grad_to_raw, to_nwgrad + +_GAP_FIELDS = ('gap_open_a', 'gap_extend_a', 'gap_open_b', 'gap_extend_b') + + +class NwgradEngine: + """ + Same interface as the Biopython engine in discrimalign: set_params(), + then scores(), then raw_subgradient() for the scores just computed. + + Double precision with pointer traceback, so paths are exactly optimal and + scores agree with Biopython's up to summation order. + """ + + def __init__(self, seqlistA, seqlistB, mode, gap_mode, substitution_mode, + alphabet, num_threads): + self.seqlistA = list(seqlistA) + self.seqlistB = list(seqlistB) + self.mode = mode + self.gap_mode = gap_mode + self.substitution_mode = substitution_mode + self.alphabet = alphabet + self.batch = nwgrad.SeqPairBatchDouble(n_threads=int(num_threads), + traceback='pointers') + self._built = False + + def set_params(self, params): + nw_params = to_nwgrad(params, self.gap_mode, self.substitution_mode, self.alphabet) + if self._built: + self.batch.set_params(nw_params) + else: + self.batch.add_many(self.seqlistA, self.seqlistB, nw_params, + gap_model=self.gap_mode, mode=self.mode, + grad_mode='hard') + self._built = True + + def scores(self): + self.batch.score_and_grad() + return [self.batch[i].score for i in range(len(self.batch))] + + def raw_subgradient(self, logit_scores, labels, alpha): + """ + sum_i (label_i - logit_score_i) * counts_i, in logit_subgradient's + format. The weighted sum is taken in nwgrad's parametrization and + converted once, which is exact because the conversion is linear. + alpha is unused, as in logit_subgradient. + """ + weights = np.asarray(labels, dtype=float) - np.asarray(logit_scores, dtype=float) + grads = [self.batch[i].grad.to_dict() for i in range(len(self.batch))] + summed = {'alphabet': grads[0]['alphabet'], + 'matrix': np.tensordot(weights, np.stack([g['matrix'] for g in grads]), axes=1)} + for field in _GAP_FIELDS: + summed[field] = float(weights @ np.array([g[field] for g in grads])) + return grad_to_raw(summed) From e6276eae15a792018d6206da68f32959fe312c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Wed, 30 Sep 2026 08:05:57 +0200 Subject: [PATCH 17/65] Test that the nwgrad backend follows the Biopython trajectory --- tests/test_discrimalign.py | 5 +- tests/test_nwgrad_backend.py | 208 +++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/test_nwgrad_backend.py diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 0be8d7f..5f3fbe6 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -175,14 +175,15 @@ def test_zero_step_keeps_parameters_and_fits_alpha(mode, gap_mode, substitution_ # --- one iteration is exactly one subgradient step -------------------------- +@pytest.mark.parametrize("backend", ["biopython", "nwgrad"]) @pytest.mark.parametrize("seed", [4, 5]) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_one_iteration_is_one_subgradient_step(mode, gap_mode, substitution_mode, seed): +def test_one_iteration_is_one_subgradient_step(mode, gap_mode, substitution_mode, seed, backend): rng, A, B, y = _data(seed) p0 = random_params(rng, gap_mode, substitution_mode) eta = 0.013 res = _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, - max_iter=1, stepfunction=create_constant_step(eta)) + max_iter=1, stepfunction=create_constant_step(eta), backend=backend) alns = align_all(A, B, make_aligner(mode, p0)) scores = np.array([a.score for a in alns]) diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py new file mode 100644 index 0000000..38f68c1 --- /dev/null +++ b/tests/test_nwgrad_backend.py @@ -0,0 +1,208 @@ +""" +The nwgrad backend must follow the Biopython backend's parameter trajectory. + +Both backends find optimal paths at the same parameters. Where raw counts can +differ (ties in simple or linear mode), the gradient in the model's own +parameters does not, so trajectories agree up to rounding. +""" +import warnings + +import numpy as np +import pytest + +from src.discrimalign import discrimalign, discrimalign_nwgrad +from src.optimization import EmptyLocalAlignment, create_constant_step, create_powerstep +from tests.helpers import (GAP_MODES, MODES, SUBSTITUTION_MODES, make_aligner, make_pairs, + random_params) + +ALL_MODES = [(m, g, s) for m in MODES for g in GAP_MODES for s in SUBSTITUTION_MODES] + +TRAJECTORY_KEYS = ("loglik_trajectory", "subgradient_l2_trajectory", + "alignment_logit_scores") + + +def _data(seed, n=12, length=12): + rng = np.random.default_rng(seed) + A, B, y = make_pairs(rng, n // 2, n - n // 2, length, sub_rate=0.25) + y[np.flatnonzero(y == 1)[0]] = 0 + y[np.flatnonzero(y == 0)[0]] = 1 + return rng, A, B, y + + +def _param_keys(gap_mode, substitution_mode): + keys = {"open_gap_score", "extend_gap_score"} if gap_mode == "affine" else {"gap_score"} + keys |= {"match_score", "mismatch_score"} if substitution_mode == "simple" else {"substitution_matrix"} + return keys | {"alpha", "final_loglik"} + + +def _both(A, B, y, mode, gap_mode, substitution_mode, **kwargs): + kwargs.setdefault("stepfunction", create_constant_step(0.01)) + kwargs.setdefault("max_iter", 3) + runs = {} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + for backend in ("biopython", "nwgrad"): + runs[backend] = discrimalign(A, B, y, aligner_mode=mode, gap_mode=gap_mode, + substitution_mode=substitution_mode, + backend=backend, **kwargs) + return runs["biopython"], runs["nwgrad"] + + +def _assert_same_fit(bio, nw, gap_mode, substitution_mode, rtol=1e-9, atol=1e-9): + for key in _param_keys(gap_mode, substitution_mode) | set(TRAJECTORY_KEYS): + np.testing.assert_allclose(np.asarray(nw[key], dtype=float), np.asarray(bio[key], dtype=float), + rtol=rtol, atol=atol, err_msg=key) + + +@pytest.mark.parametrize("seed", [0, 1]) +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_trajectory_matches_biopython_from_given_parameters(mode, gap_mode, substitution_mode, seed): + rng, A, B, y = _data(seed) + p0 = random_params(rng, gap_mode, substitution_mode) + bio, nw = _both(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0) + _assert_same_fit(bio, nw, gap_mode, substitution_mode) + + +_TERMINAL_GAP = pytest.mark.xfail(strict=True, reason=( + "nwgrad's local mode lets an alignment end (though not start) with a gap " + "column; Biopython allows neither. They agree while gap columns score " + "negative, but here the linear-gap initial estimate is positive " + "(gap_score = open + extend of an affine fit).")) + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", [ + pytest.param(*combo, marks=_TERMINAL_GAP) + if combo in {("local", "linear", "symmetric"), ("local", "linear", "general")} else combo + for combo in ALL_MODES]) +def test_trajectory_matches_biopython_from_initial_estimate(mode, gap_mode, substitution_mode): + _, A, B, y = _data(2, n=20) + bio, nw = _both(A, B, y, mode, gap_mode, substitution_mode, max_iter=3, + stepfunction=create_powerstep(1e-3)) + _assert_same_fit(bio, nw, gap_mode, substitution_mode) + + +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +def test_trajectory_matches_with_seeded_noise(substitution_mode): + rng, A, B, y = _data(3) + p0 = random_params(rng, "affine", substitution_mode) + runs = [] + for backend in ("biopython", "nwgrad"): + np.random.seed(11) + runs.append(discrimalign(A, B, y, aligner_mode="local", gap_mode="affine", + substitution_mode=substitution_mode, initial_parameters=p0, + stochastic_factor=0.3, max_iter=3, + stepfunction=create_constant_step(0.01), backend=backend)) + _assert_same_fit(runs[0], runs[1], "affine", substitution_mode) + + +def test_baseline_aligner_mode_takes_precedence_in_both_backends(): + rng, A, B, y = _data(4) + baseline = make_aligner("global", random_params(rng, "affine", "simple")) + bio, nw = _both(A, B, y, "local", "affine", "simple", baseline_aligner=baseline) + assert bio["aligner"].mode == nw["aligner"].mode == "global" + _assert_same_fit(bio, nw, "affine", "simple") + + +@pytest.mark.parametrize("num_threads", [2, 5]) +def test_nwgrad_thread_count_does_not_change_results(num_threads): + rng, A, B, y = _data(5, n=20) + p0 = random_params(rng, "affine", "general") + kwargs = dict(aligner_mode="local", gap_mode="affine", substitution_mode="general", + initial_parameters=p0, max_iter=3, stepfunction=create_constant_step(0.01), + backend="nwgrad") + one = discrimalign(A, B, y, num_threads=1, **kwargs) + many = discrimalign(A, B, y, num_threads=num_threads, **kwargs) + for key in _param_keys("affine", "general") | set(TRAJECTORY_KEYS): + np.testing.assert_array_equal(np.asarray(many[key]), np.asarray(one[key]), err_msg=key) + + +def test_nwgrad_local_pairs_without_alignment(): + A = ["AAAA", "ACGT", "CCCC", "ACGTAC", "GGGG", "ACGA"] + B = ["CCCC", "ACGT", "GGGG", "ACGTAC", "TTTT", "ACGT"] + y = np.array([0, 1, 0, 1, 0, 1]) + p0 = random_params(np.random.default_rng(6), "affine", "general") + bio, nw = _both(A, B, y, "local", "affine", "general", initial_parameters=p0) + _assert_same_fit(bio, nw, "affine", "general") + for i in (0, 2, 4): + assert isinstance(nw["alignments"][i], EmptyLocalAlignment) + + +def test_nwgrad_returns_biopython_aligner_and_alignments(): + rng, A, B, y = _data(7) + p0 = random_params(rng, "linear", "symmetric") + bio, nw = _both(A, B, y, "global", "linear", "symmetric", initial_parameters=p0) + assert type(nw["aligner"]) is type(bio["aligner"]) + assert [a.score for a in nw["alignments"]] == pytest.approx( + [a.score for a in bio["alignments"]], rel=1e-9) + + +def test_nwgrad_without_alignments(): + rng, A, B, y = _data(8) + _, nw = _both(A, B, y, "local", "affine", "simple", + initial_parameters=random_params(rng, "affine", "simple"), + return_alignments=False) + assert "alignments" not in nw + + +def test_discrimalign_nwgrad_is_the_nwgrad_backend(): + rng, A, B, y = _data(9) + p0 = random_params(rng, "affine", "symmetric") + kwargs = dict(aligner_mode="local", gap_mode="affine", substitution_mode="symmetric", + initial_parameters=p0, max_iter=2, stepfunction=create_constant_step(0.01)) + wrapped = discrimalign_nwgrad(A, B, y, **kwargs) + direct = discrimalign(A, B, y, backend="nwgrad", **kwargs) + for key in _param_keys("affine", "symmetric") | set(TRAJECTORY_KEYS): + np.testing.assert_array_equal(np.asarray(wrapped[key]), np.asarray(direct[key]), err_msg=key) + + +@pytest.mark.parametrize("gap_mode, params", [ + ("linear", {"gap_score": -1.0, "match_score": 2.0, "mismatch_score": -5.0}), + ("affine", {"open_gap_score": -1.0, "extend_gap_score": -0.5, + "match_score": 2.0, "mismatch_score": -5.0}), +]) +@pytest.mark.parametrize("a, b", [("AAA", "AAAT"), ("AAA", "TAAA"), ("AAAT", "AAA")]) +def test_local_terminal_gaps_agree_when_gaps_are_penalized(gap_mode, params, a, b): + from src.nwgrad_backend import NwgradEngine + engine = NwgradEngine([a], [b], "local", gap_mode, "simple", "ACGT", 1) + engine.set_params(params) + bio = make_aligner("local", params).score(a, b) + assert engine.scores()[0] == pytest.approx(bio) + + +@pytest.mark.parametrize("a, b", [ + pytest.param("AAA", "AAAT", marks=pytest.mark.xfail(strict=True, reason=( + "nwgrad's local mode lets an alignment end with a gap column; Biopython " + "does not. Only reachable when a gap column scores positive."))), + ("AAA", "TAAA"), # neither engine starts a local alignment with a gap +]) +def test_local_terminal_gaps_agree_when_gaps_score_positive(a, b): + from src.nwgrad_backend import NwgradEngine + params = {"gap_score": 1.0, "match_score": 2.0, "mismatch_score": -5.0} + engine = NwgradEngine([a], [b], "local", "linear", "simple", "ACGT", 1) + engine.set_params(params) + assert engine.scores()[0] == pytest.approx(make_aligner("local", params).score(a, b)) + + +def test_unknown_backend_is_rejected(): + _, A, B, y = _data(10) + with pytest.raises(AssertionError): + discrimalign(A, B, y, max_iter=0, backend="parasail") + + +def test_nwgrad_missing_stepfunction_raises(): + _, A, B, y = _data(11) + with pytest.raises(ValueError, match="stepfunction is required"): + discrimalign_nwgrad(A, B, y, max_iter=1) + + +@pytest.mark.slow +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", + [("local", "affine", "symmetric"), ("global", "linear", "simple"), + ("local", "affine", "general")]) +def test_long_trajectory_matches_biopython(mode, gap_mode, substitution_mode): + """30 iterations on 80 pairs: rounding differences must not compound into divergence.""" + rng = np.random.default_rng(0) + A, B, y = make_pairs(rng, 40, 40, 40, sub_rate=0.3, indel_rate=0.1) + bio, nw = _both(A, B, y, mode, gap_mode, substitution_mode, max_iter=30, + stepfunction=create_powerstep(1e-3)) + _assert_same_fit(bio, nw, gap_mode, substitution_mode, rtol=1e-7, atol=1e-7) From 33e3b5ddaa952bfce0a5cc691a55262457370560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Wed, 30 Sep 2026 12:42:15 +0200 Subject: [PATCH 18/65] Cap gap scores at -1e-4 so local alignment stays well-posed in both backends --- src/discrimalign.py | 20 +++++++++++ tests/test_discrimalign.py | 64 +++++++++++++++++++++++++++++++++++- tests/test_nwgrad_backend.py | 23 ++++--------- 3 files changed, 90 insertions(+), 17 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 6cb8fb4..8e9a79c 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -86,6 +86,24 @@ def _configure_aligner(aligner, params, gap_mode, substitution_mode): aligner.substitution_matrix = params['substitution_matrix'] +# Gap scores are costs and are kept at or below this value. A positive gap +# score makes local alignment ill-posed: Biopython's align() and score() +# disagree there, and the two backends end local alignments differently. +# TODO: reset to 0.0 once the backends handle the kink at a gap score of 0. +# There, zero-cost gap columns tie with none, and Biopython and nwgrad break +# the tie differently, so they return different (both valid) subgradients. +# Out of scope for the nwgrad integration. -1e-4 keeps the fit off the kink +# and well above PairwiseAligner.epsilon (1e-6), Biopython's tie tolerance. +_MAX_GAP_SCORE = -1e-4 + + +def _clip_gap_scores(params, gap_mode): + """Project gap scores onto <= _MAX_GAP_SCORE.""" + keys = ('open_gap_score', 'extend_gap_score') if gap_mode == 'affine' else ('gap_score',) + for key in keys: + params[key] = min(params[key], _MAX_GAP_SCORE) + + class _BiopythonEngine: """ Scores and subgradients from Biopython alignments: set_params(), then @@ -208,6 +226,7 @@ def add_noise(shape, niter): if verbose: print('Initial parameters:') print(updated_parameters) + _clip_gap_scores(updated_parameters, gap_mode) _configure_aligner(aligner, updated_parameters, gap_mode, substitution_mode) # The baseline aligner's mode, when given, takes precedence over aligner_mode. @@ -329,6 +348,7 @@ def alpha_fsec(alpha): subsM = subsM + add_noise(subsM.shape, iternb) updated_parameters['substitution_matrix'] += stepsize*subsM subgradient_square_norm += np.sum(subsM**2) + _clip_gap_scores(updated_parameters, gap_mode) subgradient_l2_trajectory.append(np.sqrt(subgradient_square_norm)) if verbose: print('New parameters:') diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 5f3fbe6..f0f4965 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -8,7 +8,7 @@ from Bio.Align import PairwiseAligner, substitution_matrices from scipy.special import expit -from src.discrimalign import discrimalign +from src.discrimalign import _MAX_GAP_SCORE, discrimalign from src.logit_link import logit_logL, logit_subgradient from src.optimization import (EmptyLocalAlignment, create_constant_step, create_powerstep, get_initial_estimate) @@ -43,6 +43,16 @@ def _run(seqsA, seqsB, labels, mode, gap_mode, substitution_mode, **kwargs): substitution_mode=substitution_mode, **kwargs) +GAP_KEYS = ("open_gap_score", "extend_gap_score", "gap_score") + + +def _clip_gaps(params): + """The projection discrimalign applies: gap scores are capped.""" + for key in GAP_KEYS: + if key in params: + params[key] = np.minimum(params[key], _MAX_GAP_SCORE) + + def _assert_alpha_stationary(alpha, scores, labels): """ dlogL/dalpha vanishes at alpha, to scipy.minimize's default gtol (1e-5). @@ -194,6 +204,7 @@ def test_one_iteration_is_one_subgradient_step(mode, gap_mode, substitution_mode logits = expit(alpha + scores) grad = model_gradient(logit_subgradient(alns, logits, y, alpha, DNA), gap_mode, substitution_mode) expected = {k: np.asarray(p0[k]) + eta * np.asarray(grad[k]) for k in grad} + _clip_gaps(expected) _assert_params_equal(res, expected, grad.keys(), rtol=1e-12, atol=1e-12) norm = np.sqrt(sum(np.sum(np.asarray(g) ** 2) for g in grad.values())) @@ -376,6 +387,7 @@ def test_initial_estimate_uses_default_baseline(mode, gap_mode, substitution_mod expected = get_initial_estimate( align_all(A, B, _default_baseline(mode, gap_mode, substitution_mode)), y, substitution_mode=substitution_mode, gap_mode=gap_mode, alphabet=DNA) + _clip_gaps(expected) _assert_params_equal(res, expected, _param_keys(gap_mode, substitution_mode), rtol=1e-12) assert res["alpha"] == pytest.approx(expected["alpha"], rel=1e-12) @@ -388,6 +400,7 @@ def test_initial_estimate_uses_given_baseline_aligner(): with warnings.catch_warnings(): warnings.simplefilter("ignore") expected = get_initial_estimate(align_all(A, B, baseline), y, "simple", "linear") + _clip_gaps(expected) _assert_params_equal(res, expected, {"match_score", "mismatch_score", "gap_score"}, rtol=1e-12) @@ -433,3 +446,52 @@ def test_verbose_prints_progress(capsys): assert "Alphabet:" in out assert "Start of iteration 0" in out assert "End of iteration 0" in out + + +# --- gap scores are projected onto <= 0 ------------------------------------- + +@pytest.mark.parametrize("backend", ["biopython", "nwgrad"]) +@pytest.mark.parametrize("gap_mode, positive", [ + ("affine", {"open_gap_score": 0.4}), + ("affine", {"extend_gap_score": 0.2}), + ("affine", {"open_gap_score": 0.4, "extend_gap_score": 0.2}), + ("linear", {"gap_score": 0.3}), +]) +def test_positive_starting_gap_scores_are_projected(gap_mode, positive, backend): + rng, A, B, y = _data(25) + p0 = random_params(rng, gap_mode, "simple") + negative = {k: p0[k] for k in GAP_KEYS if k in p0 and k not in positive} + p0.update(positive) + res = _run(A, B, y, "local", gap_mode, "simple", initial_parameters=p0, + max_iter=0, stepfunction=None, backend=backend) + for key in positive: + assert res[key] == _MAX_GAP_SCORE + for key, value in negative.items(): + assert res[key] == value + assert p0[next(iter(positive))] > 0 # the caller's dict is not modified + + +@pytest.mark.parametrize("backend", ["biopython", "nwgrad"]) +@pytest.mark.parametrize("mode, gap_mode", [(m, g) for m in MODES for g in GAP_MODES]) +def test_gap_scores_never_become_positive(mode, gap_mode, backend): + """Steps large enough to overshoot the cap are projected back onto it.""" + hits_cap = False + for seed in range(4): + rng, A, B, y = _data(26 + seed) + p0 = random_params(rng, gap_mode, "simple") + for key in GAP_KEYS: + if key in p0: + p0[key] = 2 * _MAX_GAP_SCORE + res = _run(A, B, y, mode, gap_mode, "simple", initial_parameters=p0, max_iter=3, + stepfunction=create_constant_step(1.0), backend=backend) + gaps = [res[k] for k in GAP_KEYS if k in res] + assert all(g <= _MAX_GAP_SCORE for g in gaps) + hits_cap |= any(g == _MAX_GAP_SCORE for g in gaps) + assert hits_cap + + +def test_substitution_scores_are_not_projected(): + rng, A, B, y = _data(30) + p0 = random_params(rng, "affine", "simple") + res = _run(A, B, y, "local", "affine", "simple", initial_parameters=p0, max_iter=2) + assert res["match_score"] > 0 diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index 38f68c1..8ec6da9 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -12,8 +12,8 @@ from src.discrimalign import discrimalign, discrimalign_nwgrad from src.optimization import EmptyLocalAlignment, create_constant_step, create_powerstep -from tests.helpers import (GAP_MODES, MODES, SUBSTITUTION_MODES, make_aligner, make_pairs, - random_params) +from tests.helpers import (GAP_MODES, MODES, SUBSTITUTION_MODES, make_aligner, + make_pairs, random_params) ALL_MODES = [(m, g, s) for m in MODES for g in GAP_MODES for s in SUBSTITUTION_MODES] @@ -63,17 +63,7 @@ def test_trajectory_matches_biopython_from_given_parameters(mode, gap_mode, subs _assert_same_fit(bio, nw, gap_mode, substitution_mode) -_TERMINAL_GAP = pytest.mark.xfail(strict=True, reason=( - "nwgrad's local mode lets an alignment end (though not start) with a gap " - "column; Biopython allows neither. They agree while gap columns score " - "negative, but here the linear-gap initial estimate is positive " - "(gap_score = open + extend of an affine fit).")) - - -@pytest.mark.parametrize("mode, gap_mode, substitution_mode", [ - pytest.param(*combo, marks=_TERMINAL_GAP) - if combo in {("local", "linear", "symmetric"), ("local", "linear", "general")} else combo - for combo in ALL_MODES]) +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) def test_trajectory_matches_biopython_from_initial_estimate(mode, gap_mode, substitution_mode): _, A, B, y = _data(2, n=20) bio, nw = _both(A, B, y, mode, gap_mode, substitution_mode, max_iter=3, @@ -171,9 +161,10 @@ def test_local_terminal_gaps_agree_when_gaps_are_penalized(gap_mode, params, a, @pytest.mark.parametrize("a, b", [ pytest.param("AAA", "AAAT", marks=pytest.mark.xfail(strict=True, reason=( - "nwgrad's local mode lets an alignment end with a gap column; Biopython " - "does not. Only reachable when a gap column scores positive."))), - ("AAA", "TAAA"), # neither engine starts a local alignment with a gap + "Positive gap scores are outside local alignment's domain: nwgrad lets a " + "local alignment end with a gap column, and Biopython's align() and " + "score() disagree with each other. discrimalign keeps gap scores negative."))), + ("AAA", "TAAA"), ]) def test_local_terminal_gaps_agree_when_gaps_score_positive(a, b): from src.nwgrad_backend import NwgradEngine From 432dab1f24aeccb6cab2a95113d32fa790d90e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Wed, 30 Sep 2026 12:55:11 +0200 Subject: [PATCH 19/65] CI: install nwgrad from a pinned GitHub commit --- .github/workflows/tests.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e326922..fe4f096 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,10 +7,16 @@ on: jobs: pytest: runs-on: ubuntu-latest + env: + # Until nwgrad 0.5.0 is released, test against this nwgrad commit. + NWGRAD_REV: 763634d7b1edaa340fc71395c639a417089e5132 steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v10.2.0 with: python-version: "3.12" - run: uv sync - - run: uv run pytest + - run: | + uv pip install "nwgrad @ git+https://github.com/michalsta/nwgrad@${NWGRAD_REV}" + uv pip show nwgrad + - run: uv run --no-sync pytest From c6006c36b62901e1171029d584cedbbbfe57298b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Wed, 30 Sep 2026 13:20:26 +0200 Subject: [PATCH 20/65] Use nwgrad's batch scores() and weighted_grad(); pin CI to nwgrad e2f847a --- .github/workflows/tests.yml | 2 +- src/nwgrad_backend.py | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fe4f096..0b0a09f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest env: # Until nwgrad 0.5.0 is released, test against this nwgrad commit. - NWGRAD_REV: 763634d7b1edaa340fc71395c639a417089e5132 + NWGRAD_REV: e2f847a01e7879344f2f9174c9397576106db0ad steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v10.2.0 diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index 5be4665..c9944f8 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -8,8 +8,6 @@ from .nwgrad_params import grad_to_raw, to_nwgrad -_GAP_FIELDS = ('gap_open_a', 'gap_extend_a', 'gap_open_b', 'gap_extend_b') - class NwgradEngine: """ @@ -44,19 +42,15 @@ def set_params(self, params): def scores(self): self.batch.score_and_grad() - return [self.batch[i].score for i in range(len(self.batch))] + return self.batch.scores() def raw_subgradient(self, logit_scores, labels, alpha): """ sum_i (label_i - logit_score_i) * counts_i, in logit_subgradient's - format. The weighted sum is taken in nwgrad's parametrization and - converted once, which is exact because the conversion is linear. - alpha is unused, as in logit_subgradient. + format. nwgrad sums the cached per-pair gradients in its own + parametrization, and the sum is converted once, which is exact + because the conversion is linear. alpha is unused, as in + logit_subgradient. """ weights = np.asarray(labels, dtype=float) - np.asarray(logit_scores, dtype=float) - grads = [self.batch[i].grad.to_dict() for i in range(len(self.batch))] - summed = {'alphabet': grads[0]['alphabet'], - 'matrix': np.tensordot(weights, np.stack([g['matrix'] for g in grads]), axes=1)} - for field in _GAP_FIELDS: - summed[field] = float(weights @ np.array([g[field] for g in grads])) - return grad_to_raw(summed) + return grad_to_raw(self.batch.weighted_grad(weights)) From 61f4fa34c8d36855ceb3ce52977c9f383a49ab27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 10:35:27 +0200 Subject: [PATCH 21/65] Fit one gap coefficient for linear gaps in the full-matrix initial estimate In symmetric and general substitution mode, get_initial_estimate() fitted the logistic regression on two gap features, the numbers of gap opens and gap extends, whatever the gap model. For linear gaps it then reported gap_score = open coefficient + extend coefficient. A linear model gives every gap column the same score, so the right feature is the total number of gap columns, with one coefficient. Summing the two coefficients of an affine fit is not that quantity. In this regression each coefficient already prices a whole gap column (an open column, or an extend column), so their sum double-counts. On synthetic data labelled with a per-column gap coefficient of -0.6, the old estimator returned -1.26 and the new one returns -0.64. Simple substitution mode already did this correctly: it fits one coefficient on counts.gaps. The fix had been parked because it did not explain the positive initial gap scores seen on one fixture. There the alignments had no gap extensions at all, so both estimators agreed exactly, and the bonus was a genuine fit. It is applied now because the nwgrad backend needs it. The initial estimate is moving onto nwgrad's per-pair gradients, which are the count vectors of the alignments. In linear mode nwgrad reports only the number of gap columns, since a linear model has no open/extend distinction to differentiate. So the open/extend split that the old estimator required cannot be computed from nwgrad at all, while the total gap count can be computed identically by both backends. Without this change the two backends could not start from the same initial estimate in these two modes, even on tie-free data. This changes the Biopython backend's initial estimate in the symmetric/general + linear modes, by design. Affine modes, simple mode, and every run given initial_parameters are unaffected. test_initial_estimate_full_linear_merges_gap_coefficients, which pinned the old sum, is replaced by a test that recovers a known per-column gap coefficient. --- src/optimization.py | 31 ++++++++++++++++--------------- tests/test_optimization.py | 15 ++++++++------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/optimization.py b/src/optimization.py index 7bebec4..f0de65a 100644 --- a/src/optimization.py +++ b/src/optimization.py @@ -85,20 +85,24 @@ def _initial_estim_lineargap_simplesubs(alignment_list, labels): 'gap_score': logit.coef_[0][2]} return estimates -def _initial_estim_affinegap_fullsubs(alignment_list, labels, - alphabet): +def _initial_estim_fullsubs(alignment_list, labels, + alphabet, gap_mode='affine'): """ Returns an initial estimator of alignment parameters using a Ridge logistic model with intercept and - a full set of predictors: numbers of gap extends and a substitution matrix + a full set of predictors: a substitution matrix plus the numbers of + gap opens and gap extends (affine) or of gap columns (linear) """ Asize = len(alphabet) predictors = [] pair_to_id = {(char1, char2): Asize*i + j for i, char1 in enumerate(alphabet) for j, char2 in enumerate(alphabet)} for aln in alignment_list: counts = aln.counts() - gaps = [counts.open_gaps, - counts.extend_gaps] + if gap_mode == 'affine': + gaps = [counts.open_gaps, + counts.extend_gaps] + else: + gaps = [counts.gaps] substitutions = [0]*(Asize**2) for char1, char2 in zip(aln[0], aln[1]): if char1 != '-' and char2 != '-': @@ -112,12 +116,13 @@ def _initial_estim_affinegap_fullsubs(alignment_list, labels, for char1 in alphabet: for char2 in alphabet: substitution_matrix[char1, char2] = logit.coef_[0][pair_to_id[(char1, char2)]] - open_gap_score = logit.coef_[0][-2] - extend_gap_score = logit.coef_[0][-1] estimates = {'alpha': logit.intercept_[0], - 'substitution_matrix': substitution_matrix, - 'open_gap_score': open_gap_score, - 'extend_gap_score': extend_gap_score} + 'substitution_matrix': substitution_matrix} + if gap_mode == 'affine': + estimates['open_gap_score'] = logit.coef_[0][-2] + estimates['extend_gap_score'] = logit.coef_[0][-1] + else: + estimates['gap_score'] = logit.coef_[0][-1] return estimates @@ -140,17 +145,13 @@ def get_initial_estimate(alignment_list, labels, elif gap_mode == 'linear': estimates = _initial_estim_lineargap_simplesubs(alignment_list, labels) else: - estimates = _initial_estim_affinegap_fullsubs(alignment_list, labels, alphabet) + estimates = _initial_estim_fullsubs(alignment_list, labels, alphabet, gap_mode) if substitution_mode == 'symmetric': # estimation of symmetric matrix should be implemented in # a separate function, for now we use this trick subsM = estimates['substitution_matrix'] subsM = (subsM.T + subsM)/2 estimates['substitution_matrix'] = subsM - if gap_mode == 'linear': - estimates['gap_score'] = estimates['open_gap_score']+ estimates['extend_gap_score'] - del estimates['open_gap_score'] - del estimates['extend_gap_score'] return estimates ### Parallel processing diff --git a/tests/test_optimization.py b/tests/test_optimization.py index 3c1f7c5..31cb58a 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -260,14 +260,15 @@ def test_initial_estimate_symmetric_is_symmetrized_general(): @pytest.mark.parametrize("substitution_mode", ["general", "symmetric"]) -def test_initial_estimate_full_linear_merges_gap_coefficients(substitution_mode): +def test_initial_estimate_full_linear_fits_one_gap_coefficient(substitution_mode): + """Labels from a linear gap model: every gap column has the same coefficient.""" rng = np.random.default_rng(4) - alns, labels = _fake_full_data(rng, 500, np.eye(3) - 0.3, -0.8, -0.2, -0.3, "ACG") - affine = get_initial_estimate(alns, labels, substitution_mode, "affine", "ACG") - linear = get_initial_estimate(alns, labels, substitution_mode, "linear", "ACG") - assert set(linear) == {"alpha", "substitution_matrix", "gap_score"} - assert linear["gap_score"] == pytest.approx( - affine["open_gap_score"] + affine["extend_gap_score"]) + gap = -0.6 + alns, labels = _fake_full_data(rng, 4000, np.eye(3) - 0.3, gap, gap, -0.3, "ACG") + est = get_initial_estimate(alns, labels, substitution_mode, "linear", "ACG") + assert set(est) == {"alpha", "substitution_matrix", "gap_score"} + # The former open + extend of an affine fit would land near 2 * gap. + assert est["gap_score"] == pytest.approx(gap, abs=0.1) def test_initial_estimate_rejects_unknown_modes(): From b97cb00baaf5fc5948b910ad395bb59fda47f7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 10:37:23 +0200 Subject: [PATCH 22/65] Split the initial estimate into features and fit; add get_initial_estimate_from_counts --- src/optimization.py | 176 ++++++++++++++++++++++---------------------- 1 file changed, 87 insertions(+), 89 deletions(-) diff --git a/src/optimization.py b/src/optimization.py index f0de65a..6166e75 100644 --- a/src/optimization.py +++ b/src/optimization.py @@ -35,89 +35,82 @@ def get_first_alignment(seqA, seqB, aligner): raise ### Starting point -def _initial_estim_affinegap_simplesubs(alignment_list, labels): +def _alignment_features(alignment_list, substitution_mode, gap_mode, alphabet): """ - Returns an initial estimator of alignment parameters - using a simple logistic model with intercept and - summary predictors: numbers of matches, mismatches, gap opens and gap extends. + Predictors for the initial estimate, one row per alignment: numbers of + matches and mismatches (simple) or of each substitution (full), then the + numbers of gap opens and gap extends (affine) or of gap columns (linear). """ predictors = [] + if substitution_mode != 'simple': + Asize = len(alphabet) + pair_to_id = {(char1, char2): Asize*i + j for i, char1 in enumerate(alphabet) for j, char2 in enumerate(alphabet)} for aln in alignment_list: counts = aln.counts() - predictors.append( - [ - counts.identities, - counts.mismatches, - counts.open_gaps, - counts.extend_gaps - ] - ) - logit = LogisticRegression(fit_intercept=True, penalty=None) - logit.fit(predictors, labels) - estimates = {'alpha': logit.intercept_[0], - 'match_score': logit.coef_[0][0], - 'mismatch_score': logit.coef_[0][1], - 'open_gap_score': logit.coef_[0][2], - 'extend_gap_score': logit.coef_[0][3]} - return estimates + if gap_mode == 'affine': + gaps = [counts.open_gaps, + counts.extend_gaps] + else: + gaps = [counts.gaps] + if substitution_mode == 'simple': + predictors.append([counts.identities, counts.mismatches] + gaps) + else: + substitutions = [0]*(Asize**2) + for char1, char2 in zip(aln[0], aln[1]): + if char1 != '-' and char2 != '-': + substitutions[pair_to_id[(char1, char2)]] += 1 + predictors.append(substitutions+gaps) + return predictors + -def _initial_estim_lineargap_simplesubs(alignment_list, labels): +def _count_features(raw_counts_list, substitution_mode, gap_mode, alphabet): """ - Returns an initial estimator of alignment parameters - using a simple logistic model with intercept and - summary predictors: numbers of matches, mismatches, gap opens and gap extends. + The same predictors as _alignment_features(), from per-alignment counts in + logit_subgradient's format ({'Substitutions', 'Gap opens', 'Gap extends'}), + such as nwgrad's per-pair gradients converted by grad_to_raw(). In linear + mode only the total number of gap columns is used, which is all that + nwgrad reports there. """ predictors = [] - for aln in alignment_list: - counts = aln.counts() - predictors.append( - [ - counts.identities, - counts.mismatches, - counts.gaps - ] - ) - logit = LogisticRegression(fit_intercept=True, penalty=None) - logit.fit(predictors, labels) - estimates = {'alpha': logit.intercept_[0], - 'match_score': logit.coef_[0][0], - 'mismatch_score': logit.coef_[0][1], - 'gap_score': logit.coef_[0][2]} - return estimates + for raw in raw_counts_list: + subs = raw['Substitutions'] + opens, extends = raw['Gap opens'], raw['Gap extends'] + gaps = [opens, extends] if gap_mode == 'affine' else [opens + extends] + if substitution_mode == 'simple': + G = np.asarray(subs) + predictors.append([np.trace(G), G.sum() - np.trace(G)] + gaps) + else: + predictors.append([subs[char1, char2] for char1 in alphabet for char2 in alphabet] + gaps) + return predictors -def _initial_estim_fullsubs(alignment_list, labels, - alphabet, gap_mode='affine'): + +def _fit_initial_estimate(predictors, labels, substitution_mode, gap_mode, alphabet): """ - Returns an initial estimator of alignment parameters - using a Ridge logistic model with intercept and - a full set of predictors: a substitution matrix plus the numbers of - gap opens and gap extends (affine) or of gap columns (linear) + Logistic regression of the labels on the predictors. Simple mode is + unpenalized; the full matrix uses the default ridge penalty. """ - Asize = len(alphabet) - predictors = [] - pair_to_id = {(char1, char2): Asize*i + j for i, char1 in enumerate(alphabet) for j, char2 in enumerate(alphabet)} - for aln in alignment_list: - counts = aln.counts() - if gap_mode == 'affine': - gaps = [counts.open_gaps, - counts.extend_gaps] - else: - gaps = [counts.gaps] - substitutions = [0]*(Asize**2) - for char1, char2 in zip(aln[0], aln[1]): - if char1 != '-' and char2 != '-': - substitutions[pair_to_id[(char1, char2)]] += 1 - predictors.append(substitutions+gaps) - - logit = LogisticRegression(fit_intercept=True, solver='newton-cg') - logit.fit(predictors, labels) - substitution_matrix = substitution_matrices.Array(data=np.zeros((Asize, Asize)), - alphabet=alphabet) - for char1 in alphabet: - for char2 in alphabet: - substitution_matrix[char1, char2] = logit.coef_[0][pair_to_id[(char1, char2)]] - estimates = {'alpha': logit.intercept_[0], - 'substitution_matrix': substitution_matrix} + if substitution_mode == 'simple': + logit = LogisticRegression(fit_intercept=True, penalty=None) + logit.fit(predictors, labels) + estimates = {'alpha': logit.intercept_[0], + 'match_score': logit.coef_[0][0], + 'mismatch_score': logit.coef_[0][1]} + else: + Asize = len(alphabet) + pair_to_id = {(char1, char2): Asize*i + j for i, char1 in enumerate(alphabet) for j, char2 in enumerate(alphabet)} + logit = LogisticRegression(fit_intercept=True, solver='newton-cg') + logit.fit(predictors, labels) + substitution_matrix = substitution_matrices.Array(data=np.zeros((Asize, Asize)), + alphabet=alphabet) + for char1 in alphabet: + for char2 in alphabet: + substitution_matrix[char1, char2] = logit.coef_[0][pair_to_id[(char1, char2)]] + if substitution_mode == 'symmetric': + # estimation of symmetric matrix should be implemented in + # a separate function, for now we use this trick + substitution_matrix = (substitution_matrix.T + substitution_matrix)/2 + estimates = {'alpha': logit.intercept_[0], + 'substitution_matrix': substitution_matrix} if gap_mode == 'affine': estimates['open_gap_score'] = logit.coef_[0][-2] estimates['extend_gap_score'] = logit.coef_[0][-1] @@ -126,6 +119,13 @@ def _initial_estim_fullsubs(alignment_list, labels, return estimates +def _check_estimate_modes(substitution_mode, gap_mode, alphabet): + assert gap_mode in {'affine', 'linear'}, 'Only linear and affine gap modes are supported' + assert substitution_mode in {'simple', 'symmetric', 'general'} + if substitution_mode != 'simple': + assert alphabet is not None, 'General and symmetric substitution mode require to specify the alphabet' + + def get_initial_estimate(alignment_list, labels, substitution_mode = 'simple', gap_mode = 'affine', @@ -135,24 +135,22 @@ def get_initial_estimate(alignment_list, labels, using a simple logistic models with intercept and summary predictors: numbers of matches, mismatches, and gaps. """ - assert gap_mode in {'affine', 'linear'}, 'Only linear and affine gap modes are supported' - assert substitution_mode in {'simple', 'symmetric', 'general'} - if substitution_mode != 'simple': - assert alphabet is not None, 'General and symmetric substitution mode require to specify the alphabet' - if substitution_mode == 'simple': - if gap_mode == 'affine': - estimates = _initial_estim_affinegap_simplesubs(alignment_list, labels) - elif gap_mode == 'linear': - estimates = _initial_estim_lineargap_simplesubs(alignment_list, labels) - else: - estimates = _initial_estim_fullsubs(alignment_list, labels, alphabet, gap_mode) - if substitution_mode == 'symmetric': - # estimation of symmetric matrix should be implemented in - # a separate function, for now we use this trick - subsM = estimates['substitution_matrix'] - subsM = (subsM.T + subsM)/2 - estimates['substitution_matrix'] = subsM - return estimates + _check_estimate_modes(substitution_mode, gap_mode, alphabet) + predictors = _alignment_features(alignment_list, substitution_mode, gap_mode, alphabet) + return _fit_initial_estimate(predictors, labels, substitution_mode, gap_mode, alphabet) + + +def get_initial_estimate_from_counts(raw_counts_list, labels, + substitution_mode='simple', + gap_mode='affine', + alphabet=None): + """ + get_initial_estimate() from per-alignment counts in logit_subgradient's + format instead of alignment objects. + """ + _check_estimate_modes(substitution_mode, gap_mode, alphabet) + predictors = _count_features(raw_counts_list, substitution_mode, gap_mode, alphabet) + return _fit_initial_estimate(predictors, labels, substitution_mode, gap_mode, alphabet) ### Parallel processing def create_alignment_workers(seqlistA, seqlistB, aligner): From 951424370f5773f91cdcba10e448d771ed92074c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 10:43:44 +0200 Subject: [PATCH 23/65] Fit the nwgrad backend's initial estimate on nwgrad's own baseline alignments --- src/discrimalign.py | 43 ++++++----- src/nwgrad_backend.py | 54 +++++++++++++- src/optimization.py | 6 +- tests/helpers.py | 27 +++++++ tests/test_discrimalign.py | 25 +------ tests/test_nwgrad_backend.py | 139 +++++++++++++++++++++++++++++++++-- 6 files changed, 246 insertions(+), 48 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 8e9a79c..ca6f52b 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -8,7 +8,8 @@ from scipy.optimize import minimize from copy import deepcopy from math import ceil -from .optimization import get_initial_estimate, get_first_alignment +from .optimization import (get_initial_estimate, get_initial_estimate_from_counts, + get_first_alignment) from .logit_link import logit_partial_scores, logit_logL, logit_subgradient @@ -149,10 +150,11 @@ def discrimalign(seqlistA, seqlistB, verbose=False, backend='biopython'): """ - backend selects where alignment scores and subgradients come from in - the iterations: 'biopython' (PairwiseAligner) or 'nwgrad'. The initial - estimate, the returned aligner and the returned alignments always use - Biopython. + backend selects where alignment scores and subgradients come from: + 'biopython' (PairwiseAligner) or 'nwgrad'. With 'nwgrad', the initial + estimate is fitted on nwgrad's alignments of the baseline too, and a + baseline_aligner must have uniform gap scores and no wildcard. The + returned aligner and alignments always use Biopython. """ # TODO: Implement tol and additional stepfunctions. assert backend in {'biopython', 'nwgrad'} @@ -212,13 +214,29 @@ def add_noise(shape, niter): aligner.substitution_matrix = substitution_matrices.Array(data=9*np.eye(len(alphabet))-4, alphabet=alphabet) - alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) - alignment_scores = [aln.score for aln in alnlist] + # The baseline aligner's mode, when given, takes precedence over aligner_mode. + if backend == 'nwgrad': + from .nwgrad_backend import NwgradEngine, baseline_parameters + engine = NwgradEngine(seqlistA, seqlistB, aligner.mode, gap_mode, + substitution_mode, alphabet, num_threads) + else: + engine = _BiopythonEngine(seqlistA, seqlistB, aligner, gap_mode, + substitution_mode, alphabet, num_threads) - # Initial logistic estimation, unless caller provides fitted parameters for a warm start. + # Initial logistic estimation from the baseline alignments, unless the + # caller provides fitted parameters for a warm start. if initial_parameters is not None: updated_parameters = deepcopy(initial_parameters) + elif backend == 'nwgrad': + engine.set_params(baseline_parameters(aligner, gap_mode, alphabet), + substitution_mode='general') + engine.scores() + updated_parameters = get_initial_estimate_from_counts(engine.raw_counts(), labels, + substitution_mode=substitution_mode, + gap_mode=gap_mode, + alphabet=alphabet) else: + alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) updated_parameters = get_initial_estimate(alnlist, labels, substitution_mode=substitution_mode, gap_mode=gap_mode, @@ -229,15 +247,6 @@ def add_noise(shape, niter): _clip_gap_scores(updated_parameters, gap_mode) _configure_aligner(aligner, updated_parameters, gap_mode, substitution_mode) - # The baseline aligner's mode, when given, takes precedence over aligner_mode. - if backend == 'nwgrad': - from .nwgrad_backend import NwgradEngine - engine = NwgradEngine(seqlistA, seqlistB, aligner.mode, gap_mode, - substitution_mode, alphabet, num_threads) - else: - engine = _BiopythonEngine(seqlistA, seqlistB, aligner, gap_mode, - substitution_mode, alphabet, num_threads) - # Subgradient refinement loglik_trajectory = [] subgradient_l2_trajectory = [] diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index c9944f8..a77251b 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -5,10 +5,51 @@ """ import numpy as np import nwgrad +from Bio.Align import substitution_matrices from .nwgrad_params import grad_to_raw, to_nwgrad +def baseline_parameters(aligner, gap_mode, alphabet): + """ + The scores of a Biopython PairwiseAligner as a DiscrimAlign parameter + dict, with the substitutions always as a matrix over alphabet. Raises + ValueError for anything the model cannot express: an unsupported mode, a + wildcard, gap scores that differ between the two sequences or between + internal and end gaps, affine gaps when gap_mode is 'linear', or a + matrix missing letters of alphabet. + """ + if aligner.mode not in ('local', 'global'): + raise ValueError(f"nwgrad backend: baseline_aligner mode {aligner.mode!r} " + "is not supported (expected 'local' or 'global')") + if aligner.wildcard is not None: + raise ValueError("nwgrad backend: baseline_aligner wildcard is not supported") + try: + if gap_mode == 'affine': + params = {'open_gap_score': aligner.open_gap_score, + 'extend_gap_score': aligner.extend_gap_score} + else: + params = {'gap_score': aligner.gap_score} + except ValueError as error: + raise ValueError( + "nwgrad backend: baseline_aligner gap scores must be the same for both " + "sequences and for internal and end gaps" + + (", and open must equal extend for gap_mode='linear'" + if gap_mode == 'linear' else "")) from error + matrix = aligner.substitution_matrix + if matrix is None: + data = np.full((len(alphabet), len(alphabet)), float(aligner.mismatch_score)) + data[np.diag_indices(len(alphabet))] = aligner.match_score + else: + missing = sorted(set(alphabet) - set(matrix.alphabet)) + if missing: + raise ValueError("nwgrad backend: baseline_aligner substitution matrix " + f"lacks letters {''.join(missing)!r}") + data = np.array([[matrix[a, b] for b in alphabet] for a in alphabet], dtype=float) + params['substitution_matrix'] = substitution_matrices.Array(alphabet=alphabet, data=data) + return params + + class NwgradEngine: """ Same interface as the Biopython engine in discrimalign: set_params(), @@ -30,8 +71,13 @@ def __init__(self, seqlistA, seqlistB, mode, gap_mode, substitution_mode, traceback='pointers') self._built = False - def set_params(self, params): - nw_params = to_nwgrad(params, self.gap_mode, self.substitution_mode, self.alphabet) + def set_params(self, params, substitution_mode=None): + """ + substitution_mode overrides the engine's own for this call, e.g. to + align a baseline given as a full matrix in a simple-mode fit. + """ + nw_params = to_nwgrad(params, self.gap_mode, + substitution_mode or self.substitution_mode, self.alphabet) if self._built: self.batch.set_params(nw_params) else: @@ -44,6 +90,10 @@ def scores(self): self.batch.score_and_grad() return self.batch.scores() + def raw_counts(self): + """Per-pair counts in logit_subgradient's format, for the scores just computed.""" + return [grad_to_raw(self.batch[i].grad) for i in range(len(self.batch))] + def raw_subgradient(self, logit_scores, labels, alpha): """ sum_i (label_i - logit_score_i) * counts_i, in logit_subgradient's diff --git a/src/optimization.py b/src/optimization.py index 6166e75..576964d 100644 --- a/src/optimization.py +++ b/src/optimization.py @@ -74,13 +74,15 @@ def _count_features(raw_counts_list, substitution_mode, gap_mode, alphabet): predictors = [] for raw in raw_counts_list: subs = raw['Substitutions'] + G = np.asarray(subs) opens, extends = raw['Gap opens'], raw['Gap extends'] gaps = [opens, extends] if gap_mode == 'affine' else [opens + extends] if substitution_mode == 'simple': - G = np.asarray(subs) predictors.append([np.trace(G), G.sum() - np.trace(G)] + gaps) else: - predictors.append([subs[char1, char2] for char1 in alphabet for char2 in alphabet] + gaps) + # Rows and columns in the order of alphabet, whatever the order of subs. + order = [subs.alphabet.index(char) for char in alphabet] + predictors.append(list(G[np.ix_(order, order)].ravel()) + gaps) return predictors diff --git a/tests/helpers.py b/tests/helpers.py index 3d04cd7..c9d3604 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -197,3 +197,30 @@ def small_fixture(seed, n=6, length=10): seqsB += [b for _, b in EDGE_PAIRS] labels = np.concatenate([labels, rng.integers(0, 2, size=len(EDGE_PAIRS))]) return rng, seqsA, seqsB, labels + + +def default_baseline(mode, gap_mode, substitution_mode, alphabet=DNA, perturb=None): + """ + discrimalign()'s default baseline aligner. With perturb=rng, every score + is shifted by uniform noise in [-0.05, 0.05]: the integer defaults tie + often (two mismatches cost one gap open), and at a tie the backends may + pick different, equally optimal alignments with different counts. + """ + noise = (lambda: 0.0) if perturb is None else (lambda: float(perturb.uniform(-0.05, 0.05))) + aligner = PairwiseAligner() + aligner.mode = mode + if gap_mode == "affine": + aligner.open_gap_score = -8 + noise() + aligner.extend_gap_score = -0.5 + noise() + else: + aligner.gap_score = -6 + noise() + n = len(alphabet) + if substitution_mode == "simple": + aligner.match_score = 5 + noise() + aligner.mismatch_score = -4 + noise() + else: + data = 9 * np.eye(n) - 4 + if perturb is not None: + data = data + perturb.uniform(-0.05, 0.05, size=(n, n)) + aligner.substitution_matrix = substitution_matrices.Array(data=data, alphabet=alphabet) + return aligner diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index f0f4965..bdf3f9a 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -13,8 +13,8 @@ from src.optimization import (EmptyLocalAlignment, create_constant_step, create_powerstep, get_initial_estimate) from tests.helpers import (DNA, GAP_MODES, MODES, SUBSTITUTION_MODES, align_all, - make_aligner, make_pairs, model_gradient, optimal_alpha, - random_params) + default_baseline, make_aligner, make_pairs, model_gradient, + optimal_alpha, random_params) ALL_MODES = [(m, g, s) for m in MODES for g in GAP_MODES for s in SUBSTITUTION_MODES] @@ -361,31 +361,14 @@ def test_protein_alphabet(): # --- baselines used for the initial estimate -------------------------------- -def _default_baseline(mode, gap_mode, substitution_mode, alphabet=DNA): - aligner = PairwiseAligner() - aligner.mode = mode - if gap_mode == "affine": - aligner.open_gap_score = -8 - aligner.extend_gap_score = -0.5 - else: - aligner.gap_score = -6 - if substitution_mode == "simple": - aligner.match_score = 5 - aligner.mismatch_score = -4 - else: - aligner.substitution_matrix = substitution_matrices.Array( - data=9 * np.eye(len(alphabet)) - 4, alphabet=alphabet) - return aligner - - @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_initial_estimate_uses_default_baseline(mode, gap_mode, substitution_mode): +def test_initial_estimate_usesdefault_baseline(mode, gap_mode, substitution_mode): _, A, B, y = _data(19, n=20) res = _run(A, B, y, mode, gap_mode, substitution_mode, max_iter=0, stepfunction=None) with warnings.catch_warnings(): warnings.simplefilter("ignore") expected = get_initial_estimate( - align_all(A, B, _default_baseline(mode, gap_mode, substitution_mode)), y, + align_all(A, B, default_baseline(mode, gap_mode, substitution_mode)), y, substitution_mode=substitution_mode, gap_mode=gap_mode, alphabet=DNA) _clip_gaps(expected) _assert_params_equal(res, expected, _param_keys(gap_mode, substitution_mode), rtol=1e-12) diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index 8ec6da9..6401378 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -5,15 +5,19 @@ differ (ties in simple or linear mode), the gradient in the model's own parameters does not, so trajectories agree up to rounding. """ +import sys import warnings import numpy as np import pytest +from Bio.Align import PairwiseAligner, substitution_matrices -from src.discrimalign import discrimalign, discrimalign_nwgrad -from src.optimization import EmptyLocalAlignment, create_constant_step, create_powerstep -from tests.helpers import (GAP_MODES, MODES, SUBSTITUTION_MODES, make_aligner, - make_pairs, random_params) +from src.discrimalign import _MAX_GAP_SCORE, discrimalign, discrimalign_nwgrad +from src.nwgrad_backend import baseline_parameters +from src.optimization import (EmptyLocalAlignment, create_constant_step, create_powerstep, + get_initial_estimate_from_counts) +from tests.helpers import (GAP_MODES, MODES, SUBSTITUTION_MODES, default_baseline, + make_aligner, make_pairs, random_params) ALL_MODES = [(m, g, s) for m in MODES for g in GAP_MODES for s in SUBSTITUTION_MODES] @@ -65,12 +69,67 @@ def test_trajectory_matches_biopython_from_given_parameters(mode, gap_mode, subs @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) def test_trajectory_matches_biopython_from_initial_estimate(mode, gap_mode, substitution_mode): - _, A, B, y = _data(2, n=20) + """From a tie-free baseline, both backends fit the same initial estimate.""" + rng, A, B, y = _data(2, n=20) + baseline = default_baseline(mode, gap_mode, substitution_mode, perturb=rng) bio, nw = _both(A, B, y, mode, gap_mode, substitution_mode, max_iter=3, - stepfunction=create_powerstep(1e-3)) + stepfunction=create_powerstep(1e-3), baseline_aligner=baseline) _assert_same_fit(bio, nw, gap_mode, substitution_mode) +@pytest.mark.parametrize("seed", [0, 1]) +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_initial_estimate_matches_biopython_from_tie_free_baseline(mode, gap_mode, + substitution_mode, seed): + rng, A, B, y = _data(20 + seed, n=20) + baseline = default_baseline(mode, gap_mode, substitution_mode, perturb=rng) + bio, nw = _both(A, B, y, mode, gap_mode, substitution_mode, max_iter=0, + stepfunction=None, baseline_aligner=baseline) + _assert_same_fit(bio, nw, gap_mode, substitution_mode, rtol=1e-12, atol=1e-12) + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_nwgrad_initial_estimate_is_fitted_on_nwgrad_counts(mode, gap_mode, substitution_mode): + """With the default (tie-prone) baseline: self-consistent, whatever ties nwgrad broke.""" + from src.nwgrad_backend import NwgradEngine, baseline_parameters + _, A, B, y = _data(3, n=20) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = discrimalign(A, B, y, aligner_mode=mode, gap_mode=gap_mode, + substitution_mode=substitution_mode, max_iter=0, backend="nwgrad") + engine = NwgradEngine(A, B, mode, gap_mode, substitution_mode, "ACGT", 1) + engine.set_params(baseline_parameters(default_baseline(mode, gap_mode, substitution_mode), + gap_mode, "ACGT"), substitution_mode="general") + engine.scores() + expected = get_initial_estimate_from_counts(engine.raw_counts(), y, substitution_mode, + gap_mode, "ACGT") + for key in _param_keys(gap_mode, substitution_mode) - {"alpha", "final_loglik"}: + value = np.asarray(expected[key], dtype=float) + if key in ("open_gap_score", "extend_gap_score", "gap_score"): + value = np.minimum(value, _MAX_GAP_SCORE) + np.testing.assert_allclose(np.asarray(res[key], dtype=float), value, rtol=1e-12, err_msg=key) + assert res["alpha"] == pytest.approx(expected["alpha"], rel=1e-12) + + +@pytest.mark.parametrize("initial", [True, False]) +def test_nwgrad_backend_needs_no_biopython_alignment(initial, monkeypatch): + """Without returned alignments, the nwgrad path never aligns with Biopython.""" + module = sys.modules["src.discrimalign"] + + def fail(*args, **kwargs): + raise AssertionError("Biopython alignment on the nwgrad path") + + monkeypatch.setattr(module, "_align_pairs", fail) + rng, A, B, y = _data(4, n=20) + kwargs = {"initial_parameters": random_params(rng, "affine", "general")} if initial else {} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + res = discrimalign(A, B, y, aligner_mode="local", gap_mode="affine", + substitution_mode="general", max_iter=2, return_alignments=False, + stepfunction=create_constant_step(0.01), backend="nwgrad", **kwargs) + assert "alignments" not in res + + @pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) def test_trajectory_matches_with_seeded_noise(substitution_mode): rng, A, B, y = _data(3) @@ -197,3 +256,71 @@ def test_long_trajectory_matches_biopython(mode, gap_mode, substitution_mode): bio, nw = _both(A, B, y, mode, gap_mode, substitution_mode, max_iter=30, stepfunction=create_powerstep(1e-3)) _assert_same_fit(bio, nw, gap_mode, substitution_mode, rtol=1e-7, atol=1e-7) + + +# --- baseline_parameters: strict conversion of a Biopython aligner ---------- + +@pytest.mark.parametrize("gap_mode", GAP_MODES) +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +def test_baseline_parameters_of_default_aligner(gap_mode, substitution_mode): + p = baseline_parameters(default_baseline("local", gap_mode, substitution_mode), gap_mode, "ACGT") + if gap_mode == "affine": + assert (p["open_gap_score"], p["extend_gap_score"]) == (-8.0, -0.5) + else: + assert p["gap_score"] == -6.0 + assert "".join(p["substitution_matrix"].alphabet) == "ACGT" + np.testing.assert_array_equal(np.asarray(p["substitution_matrix"]), 9 * np.eye(4) - 4) + + +def test_baseline_parameters_reorders_matrix_to_alphabet(): + aligner = PairwiseAligner() + aligner.substitution_matrix = substitution_matrices.Array( + alphabet="TGCAN", data=np.arange(25.0).reshape(5, 5)) + M = baseline_parameters(aligner, "affine", "ACGT")["substitution_matrix"] + assert "".join(M.alphabet) == "ACGT" + src = aligner.substitution_matrix + for a in "ACGT": + for b in "ACGT": + assert M[a, b] == src[a, b] + + +def test_baseline_parameters_affine_aligner_in_affine_mode_of_linear_scores(): + aligner = PairwiseAligner() + aligner.gap_score = -3 + p = baseline_parameters(aligner, "affine", "ACGT") + assert (p["open_gap_score"], p["extend_gap_score"]) == (-3.0, -3.0) + + +def _aligner(**scores): + aligner = PairwiseAligner() + aligner.match_score, aligner.mismatch_score = 2, -1 + aligner.open_gap_score, aligner.extend_gap_score = -5, -1 + for name, value in scores.items(): + setattr(aligner, name, value) + return aligner + + +@pytest.mark.parametrize("gap_mode, scores, message", [ + ("affine", {"end_gap_score": 0}, "internal and end gaps"), + ("affine", {"open_insertion_score": -2}, "same for both sequences"), + ("linear", {}, "open must equal extend"), + ("affine", {"wildcard": "N"}, "wildcard"), + ("affine", {"mode": "fogsaa"}, "not supported"), +]) +def test_baseline_parameters_rejects_what_the_model_cannot_express(gap_mode, scores, message): + with pytest.raises(ValueError, match=message): + baseline_parameters(_aligner(**scores), gap_mode, "ACGT") + + +def test_baseline_parameters_rejects_matrix_missing_letters(): + aligner = PairwiseAligner() + aligner.substitution_matrix = substitution_matrices.Array(alphabet="ACG", data=np.eye(3)) + with pytest.raises(ValueError, match="lacks letters 'T'"): + baseline_parameters(aligner, "affine", "ACGT") + + +def test_nwgrad_backend_rejects_nonuniform_baseline_aligner(): + _, A, B, y = _data(5) + with pytest.raises(ValueError, match="internal and end gaps"): + discrimalign(A, B, y, aligner_mode="global", gap_mode="affine", substitution_mode="simple", + baseline_aligner=_aligner(end_gap_score=0), max_iter=0, backend="nwgrad") From 10a934c9508b228b1c597f40f5ba610a88cf7851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 10:54:26 +0200 Subject: [PATCH 24/65] Run the discrimalign() API tests on both backends --- tests/test_discrimalign.py | 162 ++++++++++++++++++++++--------------- 1 file changed, 99 insertions(+), 63 deletions(-) diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index bdf3f9a..aaa335a 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -63,6 +63,13 @@ def _assert_alpha_stationary(alpha, scores, labels): assert alpha == pytest.approx(optimal_alpha(scores, labels), abs=1e-2) +BACKENDS = ["biopython", "nwgrad"] + + +def _run_on(backend, *args, **kwargs): + return _run(*args, backend=backend, **kwargs) + + def _assert_params_equal(result, expected, keys, **tol): for key in keys: np.testing.assert_allclose(np.asarray(result[key]), np.asarray(expected[key]), @@ -76,14 +83,16 @@ def _assert_params_equal(result, expected, keys, **tol): {"gap_mode": "convex"}, {"substitution_mode": "blosum"}, ]) -def test_rejects_unknown_modes(kwargs): +@pytest.mark.parametrize("backend", BACKENDS) +def test_rejects_unknown_modes(kwargs, backend): _, A, B, y = _data() with pytest.raises(AssertionError): - discrimalign(A, B, y, stepfunction=create_constant_step(0.1), max_iter=1, **kwargs) + discrimalign(A, B, y, backend=backend, stepfunction=create_constant_step(0.1), max_iter=1, **kwargs) +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("max_iter", [1, 1000]) -def test_missing_stepfunction_raises_before_any_work(max_iter, monkeypatch): +def test_missing_stepfunction_raises_before_any_work(max_iter, monkeypatch, backend): # src/__init__.py rebinds src.discrimalign to the function, so the module # is only reachable through sys.modules. module = sys.modules["src.discrimalign"] @@ -94,27 +103,30 @@ def fail(*args, **kwargs): monkeypatch.setattr(module, "_align_pairs", fail) _, A, B, y = _data() with pytest.raises(ValueError, match="stepfunction is required"): - discrimalign(A, B, y, max_iter=max_iter) + discrimalign(A, B, y, backend=backend, max_iter=max_iter) -def test_missing_stepfunction_is_fine_without_iterations(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_missing_stepfunction_is_fine_without_iterations(backend): _, A, B, y = _data() - res = _run(A, B, y, "local", "affine", "simple", max_iter=0, stepfunction=None) + res = _run_on(backend, A, B, y, "local", "affine", "simple", max_iter=0, stepfunction=None) assert res["loglik_trajectory"] == [res["final_loglik"]] -def test_single_class_labels_without_initial_parameters_raise(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_single_class_labels_without_initial_parameters_raise(backend): _, A, B, _ = _data() with pytest.raises(ValueError): - _run(A, B, np.ones(len(A), dtype=int), "local", "affine", "simple") + _run_on(backend, A, B, np.ones(len(A), dtype=int), "local", "affine", "simple") # --- result structure ------------------------------------------------------- +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_result_structure(mode, gap_mode, substitution_mode): +def test_result_structure(mode, gap_mode, substitution_mode, backend): _, A, B, y = _data() - res = _run(A, B, y, mode, gap_mode, substitution_mode, max_iter=3) + res = _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, max_iter=3) expected = _param_keys(gap_mode, substitution_mode) | { "loglik_trajectory", "subgradient_l2_trajectory", "final_loglik", "aligner", "alignments", "alignment_logit_scores", "alpha"} @@ -131,11 +143,12 @@ def test_result_structure(mode, gap_mode, substitution_mode): assert "".join(res["substitution_matrix"].alphabet) == DNA +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_final_outputs_are_consistent(mode, gap_mode, substitution_mode): +def test_final_outputs_are_consistent(mode, gap_mode, substitution_mode, backend): rng, A, B, y = _data(1) p0 = random_params(rng, gap_mode, substitution_mode) - res = _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0) + res = _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0) realigned = align_all(A, B, res["aligner"]) scores = np.array([a.score for a in res["alignments"]]) np.testing.assert_array_equal(scores, [a.score for a in realigned]) @@ -146,20 +159,22 @@ def test_final_outputs_are_consistent(mode, gap_mode, substitution_mode): assert [a.score for a in realigned] == [a.score for a in align_all(A, B, reference)] -def test_return_alignments_false_omits_alignments(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_return_alignments_false_omits_alignments(backend): _, A, B, y = _data() - res = _run(A, B, y, "local", "affine", "simple", return_alignments=False) + res = _run_on(backend, A, B, y, "local", "affine", "simple", return_alignments=False) assert "alignments" not in res assert len(res["alignment_logit_scores"]) == len(A) # --- zero iterations and zero steps ----------------------------------------- +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_zero_iterations_return_initial_parameters(mode, gap_mode, substitution_mode): +def test_zero_iterations_return_initial_parameters(mode, gap_mode, substitution_mode, backend): rng, A, B, y = _data(2) p0 = random_params(rng, gap_mode, substitution_mode) - res = _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, + res = _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, max_iter=0, stepfunction=None) _assert_params_equal(res, p0, _param_keys(gap_mode, substitution_mode)) assert res["alpha"] == p0["alpha"] @@ -168,11 +183,12 @@ def test_zero_iterations_return_initial_parameters(mode, gap_mode, substitution_ assert res["final_loglik"] == pytest.approx(logit_logL(expit(p0["alpha"] + scores), y)) +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_zero_step_keeps_parameters_and_fits_alpha(mode, gap_mode, substitution_mode): +def test_zero_step_keeps_parameters_and_fits_alpha(mode, gap_mode, substitution_mode, backend): rng, A, B, y = _data(3) p0 = random_params(rng, gap_mode, substitution_mode) - res = _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, + res = _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, max_iter=3, stepfunction=create_constant_step(0.0)) _assert_params_equal(res, p0, _param_keys(gap_mode, substitution_mode)) scores = np.array([a.score for a in res["alignments"]]) @@ -212,12 +228,13 @@ def test_one_iteration_is_one_subgradient_step(mode, gap_mode, substitution_mode assert res["loglik_trajectory"][0] == pytest.approx(logit_logL(expit(p0["alpha"] + scores), y)) +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode", MODES) -def test_small_step_increases_likelihood_at_fixed_alpha(mode): +def test_small_step_increases_likelihood_at_fixed_alpha(mode, backend): """A short step along the subgradient is an ascent direction.""" rng, A, B, y = _data(6, n=16) p0 = random_params(rng, "affine", "general") - res = _run(A, B, y, mode, "affine", "general", initial_parameters=p0, + res = _run_on(backend, A, B, y, mode, "affine", "general", initial_parameters=p0, max_iter=1, stepfunction=create_constant_step(1e-4)) p0_fitted = dict(p0, alpha=res["alpha"]) p1 = {k: res[k] for k in _param_keys("affine", "general")} @@ -232,32 +249,35 @@ def loglik(p): # --- invariants over several iterations ------------------------------------- +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode, gap_mode", [(m, g) for m in MODES for g in GAP_MODES]) -def test_symmetric_mode_keeps_matrix_symmetric(mode, gap_mode): +def test_symmetric_mode_keeps_matrix_symmetric(mode, gap_mode, backend): _, A, B, y = _data(7) - res = _run(A, B, y, mode, gap_mode, "symmetric", max_iter=4, + res = _run_on(backend, A, B, y, mode, gap_mode, "symmetric", max_iter=4, stepfunction=create_powerstep(0.05)) M = np.asarray(res["substitution_matrix"]) np.testing.assert_allclose(M, M.T, atol=1e-12) -def test_general_mode_can_become_asymmetric(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_general_mode_can_become_asymmetric(backend): rng, A, B, y = _data(8) p0 = random_params(rng, "affine", "symmetric") - res = _run(A, B, y, "global", "affine", "general", initial_parameters=p0, + res = _run_on(backend, A, B, y, "global", "affine", "general", initial_parameters=p0, max_iter=2, stepfunction=create_constant_step(0.05)) M = np.asarray(res["substitution_matrix"]) assert not np.allclose(M, M.T) +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_inputs_are_not_mutated(mode, gap_mode, substitution_mode): +def test_inputs_are_not_mutated(mode, gap_mode, substitution_mode, backend): rng, A, B, y = _data(9) p0 = random_params(rng, gap_mode, substitution_mode) baseline = make_aligner(mode, random_params(rng, gap_mode, substitution_mode)) snapshot = (deepcopy(p0), list(A), list(B), y.copy(), [a.score for a in align_all(A, B, baseline)]) - _run(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, + _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, baseline_aligner=baseline, max_iter=2, stepfunction=create_constant_step(0.1)) _assert_params_equal(p0, snapshot[0], p0.keys()) assert (A, B) == (snapshot[1], snapshot[2]) @@ -265,24 +285,26 @@ def test_inputs_are_not_mutated(mode, gap_mode, substitution_mode): assert [a.score for a in align_all(A, B, baseline)] == snapshot[4] +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", [("local", "affine", "symmetric"), ("global", "linear", "simple")]) -def test_thread_count_does_not_change_results(mode, gap_mode, substitution_mode): +def test_thread_count_does_not_change_results(mode, gap_mode, substitution_mode, backend): _, A, B, y = _data(10, n=20) - one = _run(A, B, y, mode, gap_mode, substitution_mode, max_iter=3, num_threads=1) - four = _run(A, B, y, mode, gap_mode, substitution_mode, max_iter=3, num_threads=4) + one = _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, max_iter=3, num_threads=1) + four = _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, max_iter=3, num_threads=4) _assert_params_equal(four, one, _param_keys(gap_mode, substitution_mode), rtol=0, atol=0) assert four["loglik_trajectory"] == one["loglik_trajectory"] +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("gap_mode, substitution_mode", [("affine", "simple"), ("linear", "general"), ("affine", "symmetric")]) -def test_subgradient_scale_is_equivalent_to_scaled_step(gap_mode, substitution_mode): +def test_subgradient_scale_is_equivalent_to_scaled_step(gap_mode, substitution_mode, backend): rng, A, B, y = _data(11) p0 = random_params(rng, gap_mode, substitution_mode) - scaled = _run(A, B, y, "local", gap_mode, substitution_mode, initial_parameters=p0, + scaled = _run_on(backend, A, B, y, "local", gap_mode, substitution_mode, initial_parameters=p0, max_iter=3, subgradient_scale=0.5, stepfunction=create_constant_step(0.02)) - plain = _run(A, B, y, "local", gap_mode, substitution_mode, initial_parameters=p0, + plain = _run_on(backend, A, B, y, "local", gap_mode, substitution_mode, initial_parameters=p0, max_iter=3, stepfunction=create_constant_step(0.01)) _assert_params_equal(scaled, plain, _param_keys(gap_mode, substitution_mode), rtol=1e-12) np.testing.assert_allclose(scaled["subgradient_l2_trajectory"], @@ -291,25 +313,27 @@ def test_subgradient_scale_is_equivalent_to_scaled_step(gap_mode, substitution_m # --- stochastic_factor ------------------------------------------------------ +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) -def test_stochastic_factor_is_reproducible_under_global_seed(substitution_mode): +def test_stochastic_factor_is_reproducible_under_global_seed(substitution_mode, backend): rng, A, B, y = _data(12) p0 = random_params(rng, "affine", substitution_mode) runs = [] for _ in range(2): np.random.seed(123) - runs.append(_run(A, B, y, "local", "affine", substitution_mode, initial_parameters=p0, + runs.append(_run_on(backend, A, B, y, "local", "affine", substitution_mode, initial_parameters=p0, stochastic_factor=0.5, max_iter=2)) keys = _param_keys("affine", substitution_mode) _assert_params_equal(runs[0], runs[1], keys, rtol=0, atol=0) - quiet = _run(A, B, y, "local", "affine", substitution_mode, initial_parameters=p0, max_iter=2) + quiet = _run_on(backend, A, B, y, "local", "affine", substitution_mode, initial_parameters=p0, max_iter=2) assert any(not np.allclose(np.asarray(runs[0][k]), np.asarray(quiet[k])) for k in keys) -def test_symmetric_mode_stays_symmetric_with_noise(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_symmetric_mode_stays_symmetric_with_noise(backend): rng, A, B, y = _data(13) np.random.seed(7) - res = _run(A, B, y, "global", "affine", "symmetric", + res = _run_on(backend, A, B, y, "global", "affine", "symmetric", initial_parameters=random_params(rng, "affine", "symmetric"), stochastic_factor=1.0, max_iter=3) M = np.asarray(res["substitution_matrix"]) @@ -318,51 +342,57 @@ def test_symmetric_mode_stays_symmetric_with_noise(): # --- alphabet handling ------------------------------------------------------ -def test_alphabet_inferred_from_sequences_is_sorted_union(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_alphabet_inferred_from_sequences_is_sorted_union(backend): A = ["GATTACA", "CAT", "TAG", "GGT"] B = ["GATACA", "CAT", "ACG", "TTT"] - res = _run(A, B, np.array([1, 1, 0, 0]), "local", "affine", "general", max_iter=1) + res = _run_on(backend, A, B, np.array([1, 1, 0, 0]), "local", "affine", "general", max_iter=1) assert "".join(res["substitution_matrix"].alphabet) == "ACGT" -def test_alphabet_taken_from_initial_parameters(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_alphabet_taken_from_initial_parameters(backend): rng, A, B, y = _data(14) p0 = random_params(rng, "affine", "general", alphabet="TGCA") - res = _run(A, B, y, "local", "affine", "general", initial_parameters=p0, max_iter=1) + res = _run_on(backend, A, B, y, "local", "affine", "general", initial_parameters=p0, max_iter=1) assert "".join(res["substitution_matrix"].alphabet) == "TGCA" -def test_alphabet_taken_from_baseline_aligner(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_alphabet_taken_from_baseline_aligner(backend): rng, A, B, y = _data(15) baseline = make_aligner("local", random_params(rng, "affine", "general", alphabet="CATG")) - res = _run(A, B, y, "local", "affine", "general", baseline_aligner=baseline, max_iter=1) + res = _run_on(backend, A, B, y, "local", "affine", "general", baseline_aligner=baseline, max_iter=1) assert "".join(res["substitution_matrix"].alphabet) == "CATG" -def test_explicit_alphabet_wins(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_explicit_alphabet_wins(backend): _, A, B, y = _data(16) - res = _run(A, B, y, "local", "affine", "general", alphabet="GTAC", max_iter=1) + res = _run_on(backend, A, B, y, "local", "affine", "general", alphabet="GTAC", max_iter=1) assert "".join(res["substitution_matrix"].alphabet) == "GTAC" -def test_explicit_alphabet_missing_a_letter_fails(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_explicit_alphabet_missing_a_letter_fails(backend): _, A, B, y = _data(17) with pytest.raises((KeyError, IndexError, ValueError)): - _run(A, B, y, "local", "affine", "general", alphabet="ACG", max_iter=1) + _run_on(backend, A, B, y, "local", "affine", "general", alphabet="ACG", max_iter=1) -def test_protein_alphabet(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_protein_alphabet(backend): rng = np.random.default_rng(18) protein = "ACDEFGHIKLMNPQRSTVWY" A, B, y = make_pairs(rng, 5, 5, 15, alphabet=protein, sub_rate=0.2) - res = _run(A, B, y, "local", "affine", "symmetric", alphabet=protein, max_iter=2) + res = _run_on(backend, A, B, y, "local", "affine", "symmetric", alphabet=protein, max_iter=2) assert np.asarray(res["substitution_matrix"]).shape == (20, 20) # --- baselines used for the initial estimate -------------------------------- @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_initial_estimate_usesdefault_baseline(mode, gap_mode, substitution_mode): +def test_initial_estimate_uses_default_baseline(mode, gap_mode, substitution_mode): _, A, B, y = _data(19, n=20) res = _run(A, B, y, mode, gap_mode, substitution_mode, max_iter=0, stepfunction=None) with warnings.catch_warnings(): @@ -375,10 +405,11 @@ def test_initial_estimate_usesdefault_baseline(mode, gap_mode, substitution_mode assert res["alpha"] == pytest.approx(expected["alpha"], rel=1e-12) -def test_initial_estimate_uses_given_baseline_aligner(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_initial_estimate_uses_given_baseline_aligner(backend): rng, A, B, y = _data(20, n=20) baseline = make_aligner("global", random_params(rng, "linear", "simple")) - res = _run(A, B, y, "global", "linear", "simple", baseline_aligner=baseline, + res = _run_on(backend, A, B, y, "global", "linear", "simple", baseline_aligner=baseline, max_iter=0, stepfunction=None) with warnings.catch_warnings(): warnings.simplefilter("ignore") @@ -389,41 +420,45 @@ def test_initial_estimate_uses_given_baseline_aligner(): # --- data edge cases -------------------------------------------------------- +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("gap_mode, substitution_mode", [("affine", "simple"), ("linear", "general")]) -def test_local_pairs_without_alignment(gap_mode, substitution_mode): +def test_local_pairs_without_alignment(gap_mode, substitution_mode, backend): A = ["AAAA", "ACGT", "CCCC", "ACGTAC", "GGGG", "ACGA"] B = ["CCCC", "ACGT", "GGGG", "ACGTAC", "TTTT", "ACGT"] y = np.array([0, 1, 0, 1, 0, 1]) rng = np.random.default_rng(21) p0 = random_params(rng, gap_mode, substitution_mode) - res = _run(A, B, y, "local", gap_mode, substitution_mode, initial_parameters=p0, + res = _run_on(backend, A, B, y, "local", gap_mode, substitution_mode, initial_parameters=p0, max_iter=2) for i in (0, 2, 4): assert isinstance(res["alignments"][i], EmptyLocalAlignment) assert res["alignment_logit_scores"][i] == pytest.approx(expit(res["alpha"])) -def test_single_residue_sequences(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_single_residue_sequences(backend): A = ["A", "C", "G", "T", "A", "C"] B = ["A", "C", "G", "A", "C", "T"] y = np.array([1, 1, 1, 0, 0, 0]) rng = np.random.default_rng(22) - res = _run(A, B, y, "global", "affine", "general", + res = _run_on(backend, A, B, y, "global", "affine", "general", initial_parameters=random_params(rng, "affine", "general"), max_iter=3) assert np.all(np.isfinite(np.asarray(res["substitution_matrix"]))) -def test_list_labels_behave_like_array_labels(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_list_labels_behave_like_array_labels(backend): rng, A, B, y = _data(23) p0 = random_params(rng, "affine", "simple") - as_array = _run(A, B, y, "local", "affine", "simple", initial_parameters=p0, max_iter=2) - as_list = _run(A, B, list(y), "local", "affine", "simple", initial_parameters=p0, max_iter=2) + as_array = _run_on(backend, A, B, y, "local", "affine", "simple", initial_parameters=p0, max_iter=2) + as_list = _run_on(backend, A, B, list(y), "local", "affine", "simple", initial_parameters=p0, max_iter=2) _assert_params_equal(as_list, as_array, _param_keys("affine", "simple"), rtol=0, atol=0) -def test_verbose_prints_progress(capsys): +@pytest.mark.parametrize("backend", BACKENDS) +def test_verbose_prints_progress(capsys, backend): rng, A, B, y = _data(24) - _run(A, B, y, "local", "affine", "simple", + _run_on(backend, A, B, y, "local", "affine", "simple", initial_parameters=random_params(rng, "affine", "simple"), max_iter=1, verbose=True) out = capsys.readouterr().out assert "Alphabet:" in out @@ -473,8 +508,9 @@ def test_gap_scores_never_become_positive(mode, gap_mode, backend): assert hits_cap -def test_substitution_scores_are_not_projected(): +@pytest.mark.parametrize("backend", BACKENDS) +def test_substitution_scores_are_not_projected(backend): rng, A, B, y = _data(30) p0 = random_params(rng, "affine", "simple") - res = _run(A, B, y, "local", "affine", "simple", initial_parameters=p0, max_iter=2) + res = _run_on(backend, A, B, y, "local", "affine", "simple", initial_parameters=p0, max_iter=2) assert res["match_score"] > 0 From 4859d8ea1e71bb1daea08e94c79123b739a5fb78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 10:54:46 +0200 Subject: [PATCH 25/65] Check nwgrad's gradient against finite differences of nwgrad's own scores --- tests/test_gradient_consistency.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_gradient_consistency.py b/tests/test_gradient_consistency.py index 101526a..ed0b238 100644 --- a/tests/test_gradient_consistency.py +++ b/tests/test_gradient_consistency.py @@ -145,6 +145,35 @@ def test_subgradient_matches_finite_differences(mode, gap_mode, substitution_mod assert analytic == pytest.approx(numeric, rel=1e-5, abs=1e-6), (key, index) +def _nwgrad_loglik(engine, labels, params): + engine.set_params(params) + z = params["alpha"] + engine.scores() + return float(np.sum(np.asarray(labels, dtype=float) * z - np.logaddexp(0.0, z))) + + +@pytest.mark.parametrize("seed", SEEDS) +@pytest.mark.parametrize("substitution_mode", SUBSTITUTION_MODES) +@pytest.mark.parametrize("gap_mode", GAP_MODES) +@pytest.mark.parametrize("mode", MODES) +def test_nwgrad_subgradient_matches_finite_differences(mode, gap_mode, substitution_mode, seed): + """nwgrad's gradient against central differences of nwgrad's own scores, no Biopython.""" + from src.nwgrad_backend import NwgradEngine + rng, seqsA, seqsB, labels = small_fixture(seed) + params = random_params(rng, gap_mode, substitution_mode) + engine = NwgradEngine(seqsA, seqsB, mode, gap_mode, substitution_mode, DNA, 1) + engine.set_params(params) + scores = engine.scores() + logits = 1 / (1 + np.exp(-(params["alpha"] + scores))) + grad = model_gradient(engine.raw_subgradient(logits, labels, params["alpha"]), + gap_mode, substitution_mode) + h = 1e-6 + for key, index, symmetric in _directions(params, substitution_mode): + up = _nwgrad_loglik(engine, labels, perturbed(params, key, h, index, symmetric)) + down = _nwgrad_loglik(engine, labels, perturbed(params, key, -h, index, symmetric)) + analytic = grad[key] if index is None else grad[key][index] + assert analytic == pytest.approx((up - down) / (2 * h), rel=1e-5, abs=1e-6), (key, index) + + @pytest.mark.parametrize("mode", MODES) def test_alpha_derivative_is_sum_of_residuals(mode): rng, seqsA, seqsB, labels = small_fixture(11) From cab8584cfcb82775bd76b9dbed4c5dedb5f6724f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 11:03:21 +0200 Subject: [PATCH 26/65] Reject empty sequences before any alignment work --- src/discrimalign.py | 5 +++++ tests/test_discrimalign.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/discrimalign.py b/src/discrimalign.py index ca6f52b..7670987 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -167,6 +167,11 @@ def discrimalign(seqlistA, seqlistB, 'mapping the iteration number to a step size, e.g. ' 'create_powerstep(1e-5) or create_constant_step(1e-5) from ' 'src.optimization') + for seqlist, name in ((seqlistA, 'seqlistA'), (seqlistB, 'seqlistB')): + empty = [i for i, seq in enumerate(seqlist) if len(seq) == 0] + if empty: + raise ValueError(f'{name} contains empty sequences (at indices {empty[:10]}); ' + 'every sequence needs at least one residue') if alphabet is None: alphabet = _warm_start_alphabet(baseline_aligner, initial_parameters) if alphabet is None: diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index aaa335a..1eaf357 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -106,6 +106,24 @@ def fail(*args, **kwargs): discrimalign(A, B, y, backend=backend, max_iter=max_iter) +@pytest.mark.parametrize("backend", BACKENDS) +@pytest.mark.parametrize("which", ["A", "B"]) +def test_empty_sequences_raise_before_any_work(which, backend, monkeypatch): + module = sys.modules["src.discrimalign"] + + def fail(*args, **kwargs): + raise AssertionError("aligned before validating the sequences") + + monkeypatch.setattr(module, "_align_pairs", fail) + _, A, B, y = _data() + if which == "A": + A[3] = "" + else: + B[1] = B[5] = "" + with pytest.raises(ValueError, match=f"seqlist{which} contains empty sequences"): + _run_on(backend, A, B, y, "local", "affine", "simple", max_iter=0, stepfunction=None) + + @pytest.mark.parametrize("backend", BACKENDS) def test_missing_stepfunction_is_fine_without_iterations(backend): _, A, B, y = _data() From b8af1ed7bf464a6e501c3a925896f267391c40e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 11:03:21 +0200 Subject: [PATCH 27/65] Test the nwgrad backend on protein, long, lowercase and out-of-alphabet inputs --- tests/test_nwgrad_backend.py | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index 6401378..f6417ec 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -324,3 +324,51 @@ def test_nwgrad_backend_rejects_nonuniform_baseline_aligner(): with pytest.raises(ValueError, match="internal and end gaps"): discrimalign(A, B, y, aligner_mode="global", gap_mode="affine", substitution_mode="simple", baseline_aligner=_aligner(end_gap_score=0), max_iter=0, backend="nwgrad") + + +# --- inputs beyond short DNA ------------------------------------------------ +# +# Continuous random starting parameters, so neither the start nor the fitted +# point has exactly equal scores that the backends could break differently. + +PROTEIN = "ACDEFGHIKLMNPQRSTVWY" + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", + [("local", "affine", "symmetric"), ("global", "linear", "general")]) +def test_protein_trajectory_matches_biopython(mode, gap_mode, substitution_mode): + rng = np.random.default_rng(50) + A, B, y = make_pairs(rng, 5, 5, 60, alphabet=PROTEIN, sub_rate=0.3) + p0 = random_params(rng, gap_mode, substitution_mode, alphabet=PROTEIN) + bio, nw = _both(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0, + alphabet=PROTEIN) + _assert_same_fit(bio, nw, gap_mode, substitution_mode) + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", + [("local", "affine", "general"), ("global", "linear", "simple"), + ("global", "affine", "symmetric")]) +def test_long_sequence_trajectory_matches_biopython(mode, gap_mode, substitution_mode): + rng = np.random.default_rng(51) + A, B, y = make_pairs(rng, 3, 3, 400, sub_rate=0.2, indel_rate=0.05) + p0 = random_params(rng, gap_mode, substitution_mode) + bio, nw = _both(A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0) + _assert_same_fit(bio, nw, gap_mode, substitution_mode) + + +def test_lowercase_sequences_match_biopython(): + rng, A, B, y = _data(52) + A, B = [a.lower() for a in A], [b.lower() for b in B] + p0 = random_params(rng, "affine", "general", alphabet="acgt") + bio, nw = _both(A, B, y, "local", "affine", "general", initial_parameters=p0) + _assert_same_fit(bio, nw, "affine", "general") + + +@pytest.mark.parametrize("backend", ["biopython", "nwgrad"]) +def test_out_of_alphabet_letter_raises(backend): + _, A, B, y = _data(53) + A[2] = A[2][:3] + "X" + A[2][4:] + with pytest.raises(ValueError): + discrimalign(A, B, y, aligner_mode="local", gap_mode="affine", substitution_mode="general", + alphabet="ACGT", max_iter=1, stepfunction=create_constant_step(0.01), + backend=backend) From 1662151b71a076783336b5aa2e3c1d7fbe9dc5fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 15:03:18 +0200 Subject: [PATCH 28/65] num_threads=0 means all logical cores, passed to nwgrad explicitly --- src/discrimalign.py | 10 ++++++++++ tests/test_nwgrad_backend.py | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/discrimalign.py b/src/discrimalign.py index 7670987..f8181d8 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -8,6 +8,7 @@ from scipy.optimize import minimize from copy import deepcopy from math import ceil +import os from .optimization import (get_initial_estimate, get_initial_estimate_from_counts, get_first_alignment) from .logit_link import logit_partial_scores, logit_logL, logit_subgradient @@ -155,6 +156,8 @@ def discrimalign(seqlistA, seqlistB, estimate is fitted on nwgrad's alignments of the baseline too, and a baseline_aligner must have uniform gap scores and no wildcard. The returned aligner and alignments always use Biopython. + + num_threads=0 uses all logical cores. """ # TODO: Implement tol and additional stepfunctions. assert backend in {'biopython', 'nwgrad'} @@ -172,6 +175,13 @@ def discrimalign(seqlistA, seqlistB, if empty: raise ValueError(f'{name} contains empty sequences (at indices {empty[:10]}); ' 'every sequence needs at least one residue') + if num_threads == 0: + # All logical cores, passed to nwgrad explicitly rather than its own + # n_threads=0, which picks physical cores: short pairs such as + # miRNA-target sites gain about 25% from SMT on hosts that have it. + # TODO: long-pair (e.g. protein) workflows can be faster on physical + # cores once their DP tables outgrow the cache; see TODO.md in nwgrad. + num_threads = os.cpu_count() or 1 if alphabet is None: alphabet = _warm_start_alphabet(baseline_aligner, initial_parameters) if alphabet is None: diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index f6417ec..fbc0f44 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -372,3 +372,28 @@ def test_out_of_alphabet_letter_raises(backend): discrimalign(A, B, y, aligner_mode="local", gap_mode="affine", substitution_mode="general", alphabet="ACGT", max_iter=1, stepfunction=create_constant_step(0.01), backend=backend) + + +@pytest.mark.parametrize("backend", ["biopython", "nwgrad"]) +def test_zero_threads_means_all_logical_cores(backend, monkeypatch): + import src.nwgrad_backend as nwgrad_backend + seen = [] + original = nwgrad_backend.NwgradEngine.__init__ + + def spy(self, *args): + seen.append(args[-1]) + original(self, *args) + + monkeypatch.setattr(nwgrad_backend.NwgradEngine, "__init__", spy) + monkeypatch.setattr(sys.modules["src.discrimalign"].os, "cpu_count", lambda: 3) + rng, A, B, y = _data(54) + p0 = random_params(rng, "affine", "general") + kwargs = dict(aligner_mode="local", gap_mode="affine", substitution_mode="general", + initial_parameters=p0, max_iter=2, stepfunction=create_constant_step(0.01), + backend=backend) + auto = discrimalign(A, B, y, num_threads=0, **kwargs) + one = discrimalign(A, B, y, num_threads=1, **kwargs) + if backend == "nwgrad": + assert seen == [3, 1] + for key in _param_keys("affine", "general") | set(TRAJECTORY_KEYS): + np.testing.assert_array_equal(np.asarray(auto[key]), np.asarray(one[key]), err_msg=key) From fb78497b63d7599e774f67c9a37df410ccf7d83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 15:03:18 +0200 Subject: [PATCH 29/65] CI: pin nwgrad to eeba40a (FMA-free weighted_grad) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b0a09f..89eb980 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest env: # Until nwgrad 0.5.0 is released, test against this nwgrad commit. - NWGRAD_REV: e2f847a01e7879344f2f9174c9397576106db0ad + NWGRAD_REV: eeba40acc2cff7b244617355511e049a5815a108 steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v10.2.0 From 98157c5e02cab7965f1131aab6d2280240e3226f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 15:15:00 +0200 Subject: [PATCH 30/65] Default num_threads to 0: all logical cores with nwgrad, 1 with Biopython --- src/discrimalign.py | 10 +++++++--- tests/test_nwgrad_backend.py | 28 +++++++++++++++++++--------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index f8181d8..fb36233 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -144,7 +144,7 @@ def discrimalign(seqlistA, seqlistB, stochastic_factor=None, stepfunction=None, max_iter=1000, tol=1e-3, - num_threads=1, + num_threads=0, subgradient_scale=1.0, initial_parameters=None, return_alignments=True, @@ -157,7 +157,9 @@ def discrimalign(seqlistA, seqlistB, baseline_aligner must have uniform gap scores and no wildcard. The returned aligner and alignments always use Biopython. - num_threads=0 uses all logical cores. + num_threads=0 (the default) picks the thread count automatically: all + logical cores with 'nwgrad', 1 with 'biopython', whose threads contend + for the GIL and only slow it down. """ # TODO: Implement tol and additional stepfunctions. assert backend in {'biopython', 'nwgrad'} @@ -175,7 +177,9 @@ def discrimalign(seqlistA, seqlistB, if empty: raise ValueError(f'{name} contains empty sequences (at indices {empty[:10]}); ' 'every sequence needs at least one residue') - if num_threads == 0: + if num_threads == 0 and backend == 'biopython': + num_threads = 1 + elif num_threads == 0: # All logical cores, passed to nwgrad explicitly rather than its own # n_threads=0, which picks physical cores: short pairs such as # miRNA-target sites gain about 25% from SMT on hosts that have it. diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index fbc0f44..dab6bd3 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -374,26 +374,36 @@ def test_out_of_alphabet_letter_raises(backend): backend=backend) +@pytest.mark.parametrize("explicit", [True, False]) @pytest.mark.parametrize("backend", ["biopython", "nwgrad"]) -def test_zero_threads_means_all_logical_cores(backend, monkeypatch): +def test_automatic_thread_count(backend, explicit, monkeypatch): + """num_threads=0, the default: all logical cores with nwgrad, 1 with Biopython.""" import src.nwgrad_backend as nwgrad_backend + module = sys.modules["src.discrimalign"] seen = [] - original = nwgrad_backend.NwgradEngine.__init__ + engine_init = nwgrad_backend.NwgradEngine.__init__ + align_pairs = module._align_pairs - def spy(self, *args): + def spy_engine(self, *args): seen.append(args[-1]) - original(self, *args) + engine_init(self, *args) + + def spy_align(seqsA, seqsB, aligner, num_threads): + seen.append(num_threads) + return align_pairs(seqsA, seqsB, aligner, num_threads) - monkeypatch.setattr(nwgrad_backend.NwgradEngine, "__init__", spy) - monkeypatch.setattr(sys.modules["src.discrimalign"].os, "cpu_count", lambda: 3) + monkeypatch.setattr(nwgrad_backend.NwgradEngine, "__init__", spy_engine) + monkeypatch.setattr(module, "_align_pairs", spy_align) + monkeypatch.setattr(module.os, "cpu_count", lambda: 3) rng, A, B, y = _data(54) p0 = random_params(rng, "affine", "general") kwargs = dict(aligner_mode="local", gap_mode="affine", substitution_mode="general", initial_parameters=p0, max_iter=2, stepfunction=create_constant_step(0.01), backend=backend) - auto = discrimalign(A, B, y, num_threads=0, **kwargs) + auto = discrimalign(A, B, y, **({"num_threads": 0} if explicit else {}), **kwargs) + expected = 3 if backend == "nwgrad" else 1 + assert seen and set(seen) == {expected} + seen.clear() one = discrimalign(A, B, y, num_threads=1, **kwargs) - if backend == "nwgrad": - assert seen == [3, 1] for key in _param_keys("affine", "general") | set(TRAJECTORY_KEYS): np.testing.assert_array_equal(np.asarray(auto[key]), np.asarray(one[key]), err_msg=key) From bf153006bbe56ce060ad9107b84ee1542be8773d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 15:32:38 +0200 Subject: [PATCH 31/65] Make nwgrad the default backend; remove the discrimalign_nwgrad and logit_subgradient_nwgrad stubs --- src/discrimalign.py | 8 ++------ src/logit_link.py | 3 --- tests/test_discrimalign.py | 1 + tests/test_nwgrad_backend.py | 12 ++++++------ 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index fb36233..07af270 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -149,10 +149,10 @@ def discrimalign(seqlistA, seqlistB, initial_parameters=None, return_alignments=True, verbose=False, - backend='biopython'): + backend='nwgrad'): """ backend selects where alignment scores and subgradients come from: - 'biopython' (PairwiseAligner) or 'nwgrad'. With 'nwgrad', the initial + 'nwgrad' (the default) or 'biopython' (PairwiseAligner). With 'nwgrad', the initial estimate is fitted on nwgrad's alignments of the baseline too, and a baseline_aligner must have uniform gap scores and no wildcard. The returned aligner and alignments always use Biopython. @@ -438,7 +438,3 @@ def alpha_fsec(alpha): # results['loglik_expectation_trajectory'] = loglik_expectation # results['loglik_sd_trajectory'] = loglik_sd return results - -def discrimalign_nwgrad(*args, **kwargs): - """discrimalign() with backend='nwgrad'.""" - return discrimalign(*args, backend='nwgrad', **kwargs) diff --git a/src/logit_link.py b/src/logit_link.py index e21e311..44421be 100644 --- a/src/logit_link.py +++ b/src/logit_link.py @@ -90,9 +90,6 @@ def logit_subgradient(alignment_list, logit_scores, subgradient['Substitutions'][char1, char2] += weight return subgradient -def logit_subgradient_nwgrad(): - pass - if __name__ == '__main__': seq1 = 'CCTTTCCCGGGGTCTAAGGGTT' seq2 = 'TTACCCAAAATCTGGGCC' diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 1eaf357..68b78e9 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -37,6 +37,7 @@ def _param_keys(gap_mode, substitution_mode): def _run(seqsA, seqsB, labels, mode, gap_mode, substitution_mode, **kwargs): kwargs.setdefault("stepfunction", create_constant_step(0.01)) kwargs.setdefault("max_iter", 2) + kwargs.setdefault("backend", "biopython") # tests that need nwgrad ask for it with warnings.catch_warnings(): warnings.simplefilter("ignore") # sklearn convergence noise on tiny data return discrimalign(seqsA, seqsB, labels, aligner_mode=mode, gap_mode=gap_mode, diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index dab6bd3..58485b9 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -12,7 +12,7 @@ import pytest from Bio.Align import PairwiseAligner, substitution_matrices -from src.discrimalign import _MAX_GAP_SCORE, discrimalign, discrimalign_nwgrad +from src.discrimalign import _MAX_GAP_SCORE, discrimalign from src.nwgrad_backend import baseline_parameters from src.optimization import (EmptyLocalAlignment, create_constant_step, create_powerstep, get_initial_estimate_from_counts) @@ -193,15 +193,15 @@ def test_nwgrad_without_alignments(): assert "alignments" not in nw -def test_discrimalign_nwgrad_is_the_nwgrad_backend(): +def test_default_backend_is_nwgrad(): rng, A, B, y = _data(9) p0 = random_params(rng, "affine", "symmetric") kwargs = dict(aligner_mode="local", gap_mode="affine", substitution_mode="symmetric", initial_parameters=p0, max_iter=2, stepfunction=create_constant_step(0.01)) - wrapped = discrimalign_nwgrad(A, B, y, **kwargs) - direct = discrimalign(A, B, y, backend="nwgrad", **kwargs) + default = discrimalign(A, B, y, **kwargs) + explicit = discrimalign(A, B, y, backend="nwgrad", **kwargs) for key in _param_keys("affine", "symmetric") | set(TRAJECTORY_KEYS): - np.testing.assert_array_equal(np.asarray(wrapped[key]), np.asarray(direct[key]), err_msg=key) + np.testing.assert_array_equal(np.asarray(default[key]), np.asarray(explicit[key]), err_msg=key) @pytest.mark.parametrize("gap_mode, params", [ @@ -242,7 +242,7 @@ def test_unknown_backend_is_rejected(): def test_nwgrad_missing_stepfunction_raises(): _, A, B, y = _data(11) with pytest.raises(ValueError, match="stepfunction is required"): - discrimalign_nwgrad(A, B, y, max_iter=1) + discrimalign(A, B, y, max_iter=1, backend="nwgrad") @pytest.mark.slow From 48367bd2bbba57da26536ff32b20b099e33aabb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 15:32:38 +0200 Subject: [PATCH 32/65] Run the slow learning tests on both backends --- tests/test_learning.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_learning.py b/tests/test_learning.py index f059a54..f08d4ef 100644 --- a/tests/test_learning.py +++ b/tests/test_learning.py @@ -16,13 +16,14 @@ pytestmark = pytest.mark.slow ALL_MODES = [(m, g, s) for m in MODES for g in GAP_MODES for s in SUBSTITUTION_MODES] +BACKENDS = ["biopython", "nwgrad"] -def _fit(A, B, y, mode, gap_mode, substitution_mode, max_iter=30, scale=1e-3): +def _fit(A, B, y, mode, gap_mode, substitution_mode, backend, max_iter=30, scale=1e-3): with warnings.catch_warnings(): warnings.simplefilter("ignore") return discrimalign(A, B, y, aligner_mode=mode, gap_mode=gap_mode, - substitution_mode=substitution_mode, + substitution_mode=substitution_mode, backend=backend, stepfunction=create_powerstep(scale), max_iter=max_iter) @@ -30,11 +31,12 @@ def _auc(aligner, A, B, y): return roc_auc_score(y, [a.score for a in align_all(A, B, aligner)]) +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_learning_improves_fit_and_generalizes(mode, gap_mode, substitution_mode): +def test_learning_improves_fit_and_generalizes(mode, gap_mode, substitution_mode, backend): rng = np.random.default_rng(0) A, B, y = make_pairs(rng, 40, 40, 40, sub_rate=0.3, indel_rate=0.1) - res = _fit(A, B, y, mode, gap_mode, substitution_mode) + res = _fit(A, B, y, mode, gap_mode, substitution_mode, backend) traj = res["loglik_trajectory"] assert res["final_loglik"] > traj[0] @@ -70,8 +72,9 @@ def _transitions_only(rng, seq, rate): return "".join(_TRANSITION[c] if c in "AG" and rng.random() < rate else c for c in seq) +@pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("substitution_mode", ["symmetric", "general"]) -def test_planted_substitution_is_recovered(substitution_mode): +def test_planted_substitution_is_recovered(substitution_mode, backend): """Homologs differ only by A<->G transitions, so A-G must outscore every other mismatch.""" rng = np.random.default_rng(0) A, B, y = [], [], [] @@ -83,7 +86,7 @@ def test_planted_substitution_is_recovered(substitution_mode): A.append(random_seq(rng, 40)) B.append(random_seq(rng, 40)) y.append(0) - res = _fit(A, B, np.array(y), "local", "affine", substitution_mode) + res = _fit(A, B, np.array(y), "local", "affine", substitution_mode, backend) M = res["substitution_matrix"] others = [M[a, b] for a in "ACGT" for b in "ACGT" From 80e5cf3d827a2246f207297471795eeb46a0af4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 15:34:22 +0200 Subject: [PATCH 33/65] Add a backend benchmark script --- benchmarks/bench_backends.py | 340 +++++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 benchmarks/bench_backends.py diff --git a/benchmarks/bench_backends.py b/benchmarks/bench_backends.py new file mode 100644 index 0000000..a2343d2 --- /dev/null +++ b/benchmarks/bench_backends.py @@ -0,0 +1,340 @@ +""" +Benchmark discrimalign's backends on this machine. + +Run from the repository root: + + python benchmarks/bench_backends.py # all workloads + python benchmarks/bench_backends.py --quick # smaller and faster + python benchmarks/bench_backends.py --fingerprint # hashes for cross-machine checks + +For each workload it times one subgradient iteration, split into the alignment +(and scores), the intercept fit, the gradient and the rest, for the nwgrad +backend at several thread counts and for the Biopython backend on one thread +(its threads contend for the GIL). It also times the initial estimate. +Results are printed as JSON lines (or written with --json) and summarized in a +table at the end. + +All inputs are generated with Python's own random module, which produces the +same numbers on every platform, so --fingerprint hashes from different +machines can be compared directly: nwgrad's results are meant to be +bit-identical across CPUs and instruction sets. +""" +import argparse +import hashlib +import json +import os +import platform +import random +import statistics +import sys +import time +import warnings + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import numpy as np +from Bio.Align import PairwiseAligner, substitution_matrices +from scipy.optimize import minimize + +import nwgrad +from src.discrimalign import _BiopythonEngine, _align_pairs, discrimalign +from src.logit_link import logit_logL, logit_partial_scores +from src.nwgrad_backend import NwgradEngine, baseline_parameters +from src.optimization import (create_powerstep, get_initial_estimate, + get_initial_estimate_from_counts) + +DNA = "ACGT" +PROTEIN = "ACDEFGHIKLMNPQRSTVWY" + +# name: (alphabet, mode, gap model, substitution mode, pair maker, default pair count) +WORKLOADS = { + "mirna-local": (DNA, "local", "affine", "general", "mirna", 10000), + "mirna-global": (DNA, "global", "linear", "simple", "mirna", 10000), + "protein": (PROTEIN, "local", "affine", "general", "protein", 2000), +} + + +# --- portable inputs ---------------------------------------------------------- + +def random_seq(rng, length, alphabet): + return "".join(rng.choice(alphabet) for _ in range(length)) + + +def mutate(rng, seq, alphabet, sub_rate, indel_rate): + out = [] + for char in seq: + u = rng.random() + if u < indel_rate / 2: + continue + if u < indel_rate: + out.append(rng.choice(alphabet)) + if rng.random() < sub_rate: + char = rng.choice([c for c in alphabet if c != char]) + out.append(char) + return "".join(out) or seq[0] + + +def mirna_pairs(rng, n): + """22-nt queries against 50-nt targets; every other target hides a mutated copy.""" + A, B, y = [], [], [] + for k in range(n): + query, target = random_seq(rng, 22, DNA), random_seq(rng, 50, DNA) + if k % 2 == 0: + site = mutate(rng, query, DNA, 0.15, 0.05) + pos = rng.randrange(0, 50 - len(site)) + target = target[:pos] + site + target[pos + len(site):] + A.append(query) + B.append(target[:50]) + y.append(1 - k % 2) + return A, B, np.array(y) + + +def protein_pairs(rng, n, length=300): + """Mutated homologs and unrelated pairs of 300 residues.""" + A, B, y = [], [], [] + for k in range(n): + seq = random_seq(rng, length, PROTEIN) + A.append(seq) + B.append(mutate(rng, seq, PROTEIN, 0.3, 0.05) if k % 2 == 0 else random_seq(rng, length, PROTEIN)) + y.append(1 - k % 2) + return A, B, np.array(y) + + +def random_params(rng, gap_mode, substitution_mode, alphabet): + """Non-integer parameters, so alignments with different counts do not tie.""" + params = {"alpha": rng.uniform(-1.5, -0.5)} + if gap_mode == "affine": + params["open_gap_score"] = rng.uniform(-4.0, -2.5) + params["extend_gap_score"] = rng.uniform(-1.5, -0.3) + else: + params["gap_score"] = rng.uniform(-3.0, -1.0) + if substitution_mode == "simple": + params["match_score"] = rng.uniform(1.5, 3.0) + params["mismatch_score"] = rng.uniform(-2.0, -0.5) + else: + n = len(alphabet) + data = np.array([[rng.uniform(1.5, 3.0) if i == j else rng.uniform(-2.0, -0.5) + for j in range(n)] for i in range(n)]) + if substitution_mode == "symmetric": + data = (data + data.T) / 2 + params["substitution_matrix"] = substitution_matrices.Array(alphabet=alphabet, data=data) + return params + + +def make_workload(name, n_pairs, seed=0): + alphabet, mode, gap_mode, substitution_mode, maker, _ = WORKLOADS[name] + rng = random.Random(seed) + A, B, y = (mirna_pairs if maker == "mirna" else protein_pairs)(rng, n_pairs) + params = random_params(random.Random(seed + 1), gap_mode, substitution_mode, alphabet) + return A, B, y, params, alphabet, mode, gap_mode, substitution_mode + + +def default_baseline(mode, gap_mode, substitution_mode, alphabet): + """discrimalign()'s default baseline aligner.""" + aligner = PairwiseAligner() + aligner.mode = mode + if gap_mode == "affine": + aligner.open_gap_score, aligner.extend_gap_score = -8, -0.5 + else: + aligner.gap_score = -6 + if substitution_mode == "simple": + aligner.match_score, aligner.mismatch_score = 5, -4 + else: + aligner.substitution_matrix = substitution_matrices.Array( + data=9 * np.eye(len(alphabet)) - 4, alphabet=alphabet) + return aligner + + +def make_aligner(mode, params): + aligner = PairwiseAligner() + aligner.mode = mode + if "gap_score" in params: + aligner.gap_score = params["gap_score"] + else: + aligner.open_gap_score = params["open_gap_score"] + aligner.extend_gap_score = params["extend_gap_score"] + if "substitution_matrix" in params: + aligner.substitution_matrix = params["substitution_matrix"] + else: + aligner.match_score = params["match_score"] + aligner.mismatch_score = params["mismatch_score"] + return aligner + + +# --- timing ------------------------------------------------------------------- + +def fit_alpha(scores, labels, alpha0): + target = lambda a: -logit_logL(logit_partial_scores(scores, a), labels) + fprime = lambda a: -np.sum(labels - logit_partial_scores(scores, a)) + return minimize(target, alpha0, jac=fprime)["x"][0] + + +def time_iterations(engine, params, labels, reps): + """Median seconds per part of one iteration, after one warm-up iteration.""" + parts = {k: [] for k in ("align", "alpha", "grad", "other")} + for rep in range(reps + 1): + t0 = time.perf_counter() + engine.set_params(params) + scores = engine.scores() + t1 = time.perf_counter() + logit_logL(logit_partial_scores(scores, params["alpha"]), labels) + t2 = time.perf_counter() + alpha = fit_alpha(scores, labels, params["alpha"]) + t3 = time.perf_counter() + engine.raw_subgradient(logit_partial_scores(scores, alpha), labels, alpha) + t4 = time.perf_counter() + if rep: + parts["align"].append(t1 - t0) + parts["other"].append(t2 - t1) + parts["alpha"].append(t3 - t2) + parts["grad"].append(t4 - t3) + out = {k: statistics.median(v) for k, v in parts.items()} + out["total"] = sum(out.values()) + return out + + +def bench_workload(name, n_pairs, threads, reps, biopython, emit): + A, B, y, params, alphabet, mode, gap_mode, substitution_mode = make_workload(name, n_pairs) + for t in threads: + engine = NwgradEngine(A, B, mode, gap_mode, substitution_mode, alphabet, t) + emit({"kind": "iteration", "workload": name, "pairs": n_pairs, "backend": "nwgrad", + "threads": t, **time_iterations(engine, params, y, reps)}) + if biopython: + engine = _BiopythonEngine(A, B, make_aligner(mode, params), gap_mode, + substitution_mode, alphabet, 1) + emit({"kind": "iteration", "workload": name, "pairs": n_pairs, "backend": "biopython", + "threads": 1, **time_iterations(engine, params, y, max(1, reps // 2))}) + baseline = default_baseline(mode, gap_mode, substitution_mode, alphabet) + t0 = time.perf_counter() + if biopython: + get_initial_estimate(_align_pairs(A, B, baseline, 1), y, substitution_mode, gap_mode, alphabet) + t1 = time.perf_counter() + engine = NwgradEngine(A, B, mode, gap_mode, substitution_mode, alphabet, 1) + engine.set_params(baseline_parameters(baseline, gap_mode, alphabet), substitution_mode="general") + engine.scores() + get_initial_estimate_from_counts(engine.raw_counts(), y, substitution_mode, gap_mode, alphabet) + t2 = time.perf_counter() + emit({"kind": "initial_estimate", "workload": name, "pairs": n_pairs, "threads": 1, + "biopython": (t1 - t0) if biopython else None, "nwgrad": t2 - t1}) + + +# --- fingerprints ------------------------------------------------------------- + +def sha(*arrays): + h = hashlib.sha256() + for a in arrays: + h.update(np.ascontiguousarray(np.asarray(a, dtype=np.float64)).tobytes()) + return h.hexdigest()[:16] + + +def fingerprints(emit): + """ + Hashes of nwgrad's scores and weighted gradient, and of a 10-iteration fit + (fitted parameters and intercept), from fixed portable inputs. The fit's + reported log-likelihood is left out: numpy may sum it in a different order + on different CPUs. + """ + for name in ("mirna-local", "mirna-global"): + A, B, y, params, alphabet, mode, gap_mode, substitution_mode = make_workload(name, 2000, seed=7) + record = {"kind": "fingerprint", "workload": name} + weights = np.array([2 * k / (len(A) - 1) - 1 for k in range(len(A))]) + for t in (1, 4): + engine = NwgradEngine(A, B, mode, gap_mode, substitution_mode, alphabet, t) + engine.set_params(params) + scores = engine.scores() + g = engine.batch.weighted_grad(weights).to_dict() + record[f"engine_threads_{t}"] = sha(scores, g["matrix"], + [g[k] for k in ("gap_open_a", "gap_extend_a", + "gap_open_b", "gap_extend_b")]) + result = discrimalign(A, B, y, aligner_mode=mode, gap_mode=gap_mode, + substitution_mode=substitution_mode, initial_parameters=params, + max_iter=10, stepfunction=create_powerstep(1e-3), + return_alignments=False, backend="nwgrad", num_threads=1) + keys = [k for k in ("substitution_matrix", "match_score", "mismatch_score", + "open_gap_score", "extend_gap_score", "gap_score") if k in result] + record["fit"] = sha(*[result[k] for k in keys], result["alpha"]) + emit(record) + + +# --- main --------------------------------------------------------------------- + +def default_threads(): + n = os.cpu_count() or 1 + threads, t = [], 1 + while t < n: + threads.append(t) + t *= 2 + return threads + [n, 0] + + +def summarize(records): + lines = [] + for name in WORKLOADS: + rows = [r for r in records if r["kind"] == "iteration" and r["workload"] == name] + if not rows: + continue + bio = next((r for r in rows if r["backend"] == "biopython"), None) + lines.append(f"\n{name}, {rows[0]['pairs']} pairs: seconds per iteration") + lines.append(f" {'backend':10s} {'threads':>7s} {'align':>8s} {'alpha':>8s} {'grad':>8s} " + f"{'other':>8s} {'total':>8s} {'vs Biopython':>13s}") + for r in rows: + label = "auto" if r["threads"] == 0 else str(r["threads"]) + speedup = f"{bio['total'] / r['total']:.1f}x" if bio else "-" + lines.append(f" {r['backend']:10s} {label:>7s} {r['align']:8.3f} {r['alpha']:8.3f} " + f"{r['grad']:8.3f} {r['other']:8.3f} {r['total']:8.3f} {speedup:>13s}") + for r in records: + if r["kind"] == "initial_estimate" and r["workload"] == name: + bio_s = f"{r['biopython']:.2f} s" if r["biopython"] is not None else "-" + lines.append(f" initial estimate (1 thread): Biopython {bio_s}, nwgrad {r['nwgrad']:.2f} s") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0].strip()) + parser.add_argument("--workloads", default=",".join(WORKLOADS), + help=f"comma-separated subset of: {', '.join(WORKLOADS)}") + parser.add_argument("--pairs", type=int, help="pair count for every workload " + "(default: 10000 for miRNA-sized pairs, 2000 for proteins)") + parser.add_argument("--threads", help="comma-separated nwgrad thread counts; 0 means " + "automatic (default: powers of 2 up to os.cpu_count(), that count, and 0)") + parser.add_argument("--reps", type=int, default=5, help="timed iterations per measurement") + parser.add_argument("--quick", action="store_true", help="a tenth of the pairs, 2 repetitions") + parser.add_argument("--no-biopython", action="store_true", help="skip the Biopython backend") + parser.add_argument("--fingerprint", action="store_true", + help="only print fingerprints for comparing results across machines") + parser.add_argument("--json", help="also write the records to this file") + args = parser.parse_args() + warnings.simplefilter("ignore") + + records = [] + sink = open(args.json, "w") if args.json else None + + def emit(record): + records.append(record) + line = json.dumps(record) + print(line, flush=True) + if sink: + sink.write(line + "\n") + sink.flush() + + emit({"kind": "environment", "cpu_count": os.cpu_count(), "machine": platform.machine(), + "system": platform.system(), "python": platform.python_version(), + "nwgrad_isa": nwgrad.simd_isa(), "nwgrad_compiled_with": nwgrad.compiled_with(), + "numpy": np.__version__}) + if args.fingerprint: + fingerprints(emit) + return + threads = [int(t) for t in args.threads.split(",")] if args.threads else default_threads() + reps = 2 if args.quick else args.reps + for name in args.workloads.split(","): + n = args.pairs or WORKLOADS[name][5] + if args.quick and not args.pairs: + n //= 10 + bench_workload(name, n, threads, reps, not args.no_biopython, emit) + print(summarize(records)) + if sink: + sink.close() + + +if __name__ == "__main__": + main() From 9aefcfcae447af3cb7aabfec1f83f6e32576e6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 15:35:05 +0200 Subject: [PATCH 34/65] README: backends, thread count, behavior changes, benchmarks; fix the usage example --- README.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b78eee4..205b109 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ DiscrimAlign is a research codebase for discriminatively learning alignment para ```text src/ Core DiscrimAlign implementation tests/ Unit and integration tests (pytest) +benchmarks/ Backend benchmark script Simulation experiments.ipynb Simulation experiments for the manuscript pyproject.toml Project environment managed by uv case_study_for_mirna/ miRNA case study, trained models, and evaluation instructions @@ -16,6 +17,7 @@ case_study_for_mirna/ miRNA case study, trained models, and evaluation i - Python `>=3.10` - `uv` for environment management +- `nwgrad` 0.5.0 or later, the default alignment backend. `uv sync` installs it as a binary wheel. Building it from source needs a C++20 compiler that provides ``, such as GCC; on macOS use Homebrew GCC (`CC=gcc-16 CXX=g++-16`), since Apple's clang does not provide it. - JupyterLab or VS Code notebook support for running `Simulation experiments.ipynb` The repository uses a single project environment managed by `uv`. This environment includes the scientific Python dependencies, JupyterLab, an IPython kernel for notebooks, and `miRBench` for the miRNA case-study dataset interface. @@ -82,6 +84,7 @@ The main function is `discrimalign` from `src.discrimalign`. ```python from src.discrimalign import discrimalign +from src.optimization import create_powerstep seqlistA = ["AUGCUA", "CUGA"] seqlistB = ["AUGGUA", "CUGU"] @@ -94,7 +97,7 @@ result = discrimalign( aligner_mode="local", gap_mode="affine", substitution_mode="symmetric", - num_threads=1, + stepfunction=create_powerstep(1e-5), ) print(result["final_loglik"]) @@ -103,18 +106,57 @@ print(result["alpha"]) The returned object contains the fitted aligner, learned alignment parameters, intercept, final log-likelihood, and optimization trajectories. -Parallel alignment during fitting is chunked when `num_threads > 1`. Each joblib task processes a chunk of sequence pairs rather than a single pair, which reduces scheduler overhead across repeated optimization iterations while preserving alignment order. Thread-based joblib workers are used for the chunked alignment tasks. +`stepfunction` maps the iteration number to a step size and is required whenever `max_iter > 0`; `src.optimization` provides `create_powerstep` and `create_constant_step`. + +`num_threads=0`, the default, chooses the thread count automatically: all logical cores with the nwgrad backend, and one thread with the Biopython backend, whose threads contend for Python's global interpreter lock and only slow it down. Any other value is used as given. For long sequences such as full-length proteins, the number of physical cores can be faster than all logical cores; pass it explicitly. + +### Backends + +`backend` selects where alignment scores and subgradients come from: + +- `"nwgrad"` (default): the [nwgrad](https://github.com/michalsta/nwgrad) C++ library aligns all pairs in parallel and returns each alignment's score and gradient in one pass. The pairs are encoded once and reused across iterations. +- `"biopython"`: Biopython's `PairwiseAligner`, with the gradient counted in Python from the alignment strings. + +Both backends fit the same model and return the same kinds of objects: the returned `aligner` and `alignments` are Biopython objects with either backend. With `"nwgrad"` and `return_alignments=True`, the final alignments are computed with Biopython once at the end; pass `return_alignments=False` to skip that pass. + +The backends agree up to tie-breaking. When two alignments of a pair score exactly the same but use different substitutions or gaps, the backends may pick different ones. Both are valid subgradients, but the fits then drift apart. Exact ties are common with integer-valued scores, such as the default baseline aligner, and with fitted substitution matrices that contain exactly equal entries, such as the zeros a ridge fit assigns to substitutions that never occur in the data. Away from ties, the two backends follow the same trajectory up to floating-point rounding. + +With `"nwgrad"`, the initial estimate is fitted on nwgrad's own alignments of the baseline. A `baseline_aligner` must then be expressible in DiscrimAlign's model: the same gap scores for both sequences and for internal and end gaps, no wildcard, and `local` or `global` mode. Otherwise a `ValueError` explains what is unsupported. + +On the x86-64 machines tested, one fitting iteration on 10,000 miRNA-sized pairs (22 × 50 nt, local alignment, affine gaps, full substitution matrix) was 13–22× faster with nwgrad on one thread than with Biopython, and 77–212× faster with all cores. On 300-residue proteins, where Biopython's C alignment is more competitive, it was 3–5× faster on one thread and 26–108× with all cores. See `benchmarks/` to measure your own machine. + +### Behavior changes + +Compared with earlier versions of DiscrimAlign: + +- Gap scores are kept at or below `-1e-4`, both at the start and after every step. A positive gap score rewards gaps, which makes local alignment ill-posed: Biopython's local mode gives inconsistent answers for it, and the two backends would disagree. +- Subgradient gap counts are fixed for alignments that switch directly between a gap in one sequence and a gap in the other. Each switch now counts as a new gap opening, as Biopython scores it; it was previously counted as an extension. +- In `symmetric` and `general` substitution mode with linear gaps, the initial estimate fits one coefficient on the number of gap columns. It previously added the gap-open and gap-extend coefficients of an affine fit. +- Empty sequences and a missing `stepfunction` with `max_iter > 0` raise a `ValueError` before any alignment work. +- The default backend is nwgrad, and `num_threads` defaults to automatic. ## Running tests `uv sync` installs `pytest` with the default `dev` dependency group. From the repository root: ```bash -uv run pytest # full suite, about 30 s +uv run pytest # full suite, about a minute uv run pytest -m "not slow" # skip the end-to-end learning runs ``` -Tests marked `xfail(strict=True)` document known bugs; they start failing once the bug is fixed, and the marker should then be removed. +Tests marked `xfail(strict=True)` document known, accepted differences or bugs; they start failing once the behavior changes, and the marker should then be removed. + +## Benchmarks + +`benchmarks/bench_backends.py` times both backends on the current machine: one fitting iteration split into its parts, at several thread counts, plus the initial estimate, for miRNA-sized and protein-sized workloads. + +```bash +uv run python benchmarks/bench_backends.py --quick # a few minutes +uv run python benchmarks/bench_backends.py # full size +uv run python benchmarks/bench_backends.py --fingerprint # hashes to compare machines +``` + +The inputs are generated with Python's own random number generator, which gives the same numbers on every platform, so `--fingerprint` output from different machines can be compared directly. nwgrad's results are meant to be bit-identical across CPUs and instruction sets. ## miRNA case study From bb0ec20ba35e6498ae86808607971a3c265f345b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 15:59:52 +0200 Subject: [PATCH 35/65] Add TODO-nwgrad.md listing the deferred follow-ups --- README.md | 1 + TODO-nwgrad.md | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 TODO-nwgrad.md diff --git a/README.md b/README.md index 205b109..f25eab8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ DiscrimAlign is a research codebase for discriminatively learning alignment para src/ Core DiscrimAlign implementation tests/ Unit and integration tests (pytest) benchmarks/ Backend benchmark script +TODO-nwgrad.md Follow-ups to the nwgrad backend Simulation experiments.ipynb Simulation experiments for the manuscript pyproject.toml Project environment managed by uv case_study_for_mirna/ miRNA case study, trained models, and evaluation instructions diff --git a/TODO-nwgrad.md b/TODO-nwgrad.md new file mode 100644 index 0000000..2b1919b --- /dev/null +++ b/TODO-nwgrad.md @@ -0,0 +1,80 @@ +# TODO: follow-ups to the nwgrad backend + +Items deliberately left out of the nwgrad backend work, roughly in the order +they become relevant. + +## At the nwgrad 0.5.0 release + +- **Dependency:** in `pyproject.toml`, require `nwgrad>=0.5.0`. + `SeqPairBatch.scores()` and `SeqPairBatch.weighted_grad()` first appear in + 0.5.0. +- **CI:** in `.github/workflows/tests.yml`, remove the step that installs + nwgrad from a pinned GitHub commit (`NWGRAD_REV`), so CI tests the released + wheel, and confirm one green run against it. +- Until then, a local `uv sync` installs nwgrad 0.4.0 from PyPI, which lacks + the API this code uses. + +## Validation + +- **Real data:** run both backends on the miRNA case study and compare the + fitted models. So far, equivalence is established on simulated data only: + DNA and protein, up to 400 residues, at most 80 pairs per fit in the test + suite. +- **Notebooks:** rerun the simulation notebooks under the new defaults + (`backend="nwgrad"`, gap cap, linear-gap estimator fix). Their results will + shift slightly. + +## Known issues in DiscrimAlign + +- **scikit-learn 1.10** removes `LogisticRegression(penalty=None)`, used by the + simple-mode initial estimators in `src/optimization.py`. They will stop + working; the replacement is `C=np.inf`. It is deprecated, with a warning, + since 1.8. +- **`logit_logL` precision** (`src/logit_link.py`): it computes `log(p)` and + `log1p(-p)` from `p = expit(z)`, which loses precision on confident + predictions, and clips each pair's loss at about 36. A stable form computes + `sum(y*z - logaddexp(0, z))` from the logits. +- **The intercept fit** (`scipy.optimize.minimize`, default BFGS tolerance) + leaves α about 1e-4 from the optimum where the likelihood is flat. It is + also the largest per-iteration cost at high thread counts: the alignment + itself drops to under half of an iteration. A one-dimensional Newton or + bracketing solve on dL/dα would be exact and cheaper. +- **`src.discrimalign` is shadowed:** `src/__init__.py` imports the function + under the module's name, so `import src.discrimalign` binds the function. + Use `sys.modules["src.discrimalign"]` to reach the module. +- **`tol` is accepted but unused** (TODO in `discrimalign()`). + +## The gap-score cap + +`discrimalign()` keeps gap scores at or below `_MAX_GAP_SCORE = -1e-4` +(`src/discrimalign.py`). The cap should be 0. It is not, because at a gap score +of exactly 0, zero-cost gap columns tie with no gap: the backends return equal +scores but different gap counts, i.e. different valid subgradients, and the +fits diverge. Reset the cap to 0 once that kink is handled consistently, e.g. +by making both backends break such ties the same way. + +## nwgrad-side follow-ups + +- **Local mode with positive gap scores.** nwgrad lets a local alignment end + with a gap column but not start with one. That only differs from the + alternatives when a gap column scores positive, which the cap prevents. + Biopython's local mode is itself inconsistent there, so there is no reference + to match. One strict xfail in `tests/test_nwgrad_backend.py` documents it. A + clean definition would require local alignments to start and end with an + aligned column, in every DP fill. +- **Default thread count.** nwgrad's `n_threads=0` picks physical cores, which + is about 15–25% slower than all logical cores for short pairs, where the DP + tables stay in cache. See `TODO.md` in nwgrad. DiscrimAlign therefore resolves + `num_threads=0` to `os.cpu_count()` itself; long-sequence (protein) workloads + may do better with physical cores (TODO in `discrimalign()`). +- **Per-pair gradients in bulk.** The initial estimate reads every pair's + gradient through `batch[i].grad.to_dict()`, about 1.4 s per 50,000 pairs. A + batch call returning all per-pair gradients as one array would remove that. +- **float32.** The backend uses nwgrad's double-precision classes with pointer + traceback. The float32 classes would be faster, but nwgrad's default float32 + traceback can return a suboptimal path, and Biopython works in double. + +## Pending upstream work + +- **PR #10** (`theta_trajectory`) edits the optimization loop this work + refactored. It needs to be ported onto the shared loop in `discrimalign()`. From 09de7e23d13d57d8e4077bdad29659d64fc4df71 Mon Sep 17 00:00:00 2001 From: Elissavet Zacharopoulou Date: Thu, 1 Oct 2026 19:20:32 +0000 Subject: [PATCH 36/65] Add miRNA inference interface --- README.md | 121 +++++++++++++++++++- case_study_for_mirna/README.md | 24 ++++ examples/README.md | 20 ++++ examples/mirna_pairs.csv | 5 + src/__init__.py | 10 ++ src/discrimalign.py | 4 +- src/infer.py | 52 +++++++++ src/inference.py | 202 +++++++++++++++++++++++++++++++++ tests/test_discrimalign.py | 22 ++++ tests/test_inference.py | 68 +++++++++++ 10 files changed, 522 insertions(+), 6 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/mirna_pairs.csv create mode 100644 src/infer.py create mode 100644 src/inference.py create mode 100644 tests/test_discrimalign.py create mode 100644 tests/test_inference.py diff --git a/README.md b/README.md index b6305c8..2c97dbf 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # DiscrimAlign -DiscrimAlign is a research codebase for discriminatively learning alignment parameters from labelled pairs of biological sequences. The repository contains the core implementation of the method, the simulation experiments used in the manuscript, a stable `uv` environment, and a manuscript-aligned miRNA case study. +DiscrimAlign provides a ready-to-run miRNA sequence-pair inference workflow backed by bundled trained models, plus the research code used to train and evaluate those models. The main user-facing path is: provide a CSV of sequence pairs, choose a bundled miRNA model, and receive probabilities plus human-readable alignments. ## Repository structure ```text -src/ Core DiscrimAlign implementation +src/ Core DiscrimAlign training and inference code +examples/mirna_pairs.csv Ready-to-run inference input example +case_study_for_mirna/ Bundled miRNA trained models and evaluation workflows Simulation experiments.ipynb Simulation experiments for the manuscript pyproject.toml Project environment managed by uv -case_study_for_mirna/ miRNA case study, trained models, and evaluation instructions ``` ## Requirements @@ -55,6 +56,82 @@ uv sync All project commands are run through this environment with `uv run`. +## miRNA Inference Quickstart + +Use this workflow when you want predictions from the bundled trained miRNA models without retraining. + +### 1. Inspect the Example Input + +A ready-to-run CSV is provided at: + +```text +examples/mirna_pairs.csv +``` + +It uses this schema: + +```csv +id,sequence_a,sequence_b +example_positive_like,AUGCUA,AUGGUA +example_short,CUGA,CUGU +``` + +Required columns: + +- `sequence_a`: first miRNA/RNA sequence +- `sequence_b`: second miRNA/RNA sequence +- any extra columns, such as `id`, are preserved in the output + +### 2. Run a Bundled Model + +Two trained miRNA model aliases are available: + +- `manakov`: `case_study_for_mirna/trained_models/manakov_best_model.pkl` +- `hejret`: `case_study_for_mirna/trained_models/hejret_best_model.pkl` + +Run inference: + +```bash +uv run python -m src.infer \ + --model manakov \ + --input examples/mirna_pairs.csv \ + --output predictions_manakov.csv +``` + +Or use the Hejret-trained model: + +```bash +uv run python -m src.infer \ + --model hejret \ + --input examples/mirna_pairs.csv \ + --output predictions_hejret.csv +``` + +### 3. Read the Output + +The output CSV includes: + +- `probability`: logistic model probability for the positive class +- `alignment_score`: score assigned by the fitted aligner +- `aligned_sequence_a`, `alignment_marks`, `aligned_sequence_b`: readable alignment +- `operations`: per-position `match`, `mismatch`, or `gap` +- `normalized_sequence_a`, `normalized_sequence_b`: sequences actually scored by the model + +By default, `--normalize auto` converts `U`/`T` to match the trained model alphabet. Use `--normalize none` only if you want to disable this behavior. + +### 4. Use Your Own CSV Columns + +If your input columns have different names, pass them explicitly: + +```bash +uv run python -m src.infer \ + --model manakov \ + --input my_pairs.csv \ + --output my_predictions.csv \ + --seq-a-column mirna \ + --seq-b-column target +``` + ## Simulation experiments The notebook @@ -75,9 +152,9 @@ uv run jupyter lab "Simulation experiments.ipynb" With the Python and Jupyter extensions installed, open `Simulation experiments.ipynb` and select the kernel associated with the local `.venv/` environment. -## Core DiscrimAlign usage +## Training API -The main function is `discrimalign` from `src.discrimalign`. +Use `discrimalign` directly when you want to fit a new model instead of using the bundled miRNA models. ```python from src.discrimalign import discrimalign @@ -101,6 +178,40 @@ print(result["alpha"]) ``` The returned object contains the fitted aligner, learned alignment parameters, intercept, final log-likelihood, and optimization trajectories. +If `stepfunction` is omitted, `discrimalign` uses a conservative default power step with scale `1e-4`. + +For inference on new sequence pairs, use `predict_pairs` with the fitted result: + +```python +from src import predict_pairs + +rows = predict_pairs( + seqlistA=["AUGCUA"], + seqlistB=["AUGGUA"], + model=result, +) + +print(rows[0]["probability"]) +print(rows[0]["aligned_sequence_a"]) +print(rows[0]["alignment_marks"]) +print(rows[0]["aligned_sequence_b"]) +``` + +Each inference row is a plain dictionary with the input sequences, normalized sequences, alignment score, logistic probability, aligned strings, match markers, and per-position operations (`match`, `mismatch`, or `gap`). By default, inference normalizes `U`/`T` automatically to match the fitted model alphabet; pass `--normalize none` in the CLI to disable this. + +To persist a fitted model and run CSV inference later: + +```python +from src import save_model + +save_model(result, "model.pkl") +``` + +Run inference with a custom saved model: + +```bash +uv run python -m src.infer --model model.pkl --input examples/mirna_pairs.csv --output predictions.csv +``` Parallel alignment during fitting is chunked when `num_threads > 1`. Each joblib task processes a chunk of sequence pairs rather than a single pair, which reduces scheduler overhead across repeated optimization iterations while preserving alignment order. Thread-based joblib workers are used for the chunked alignment tasks. diff --git a/case_study_for_mirna/README.md b/case_study_for_mirna/README.md index bc530ec..84bd996 100644 --- a/case_study_for_mirna/README.md +++ b/case_study_for_mirna/README.md @@ -155,3 +155,27 @@ uv run python case_study_for_mirna/case_study_mirna.py \ ``` The metrics for the user-provided set are added to the same output files as the miRBench evaluation splits. + +## Inference with bundled trained models + +The repository includes two fitted miRNA case-study models: + +- `case_study_for_mirna/trained_models/manakov_best_model.pkl` +- `case_study_for_mirna/trained_models/hejret_best_model.pkl` + +Create a CSV with sequence-pair columns, or use the ready-to-run example at `examples/mirna_pairs.csv`: + +```csv +id,sequence_a,sequence_b +pair_1,AUGCUA,AUGGUA +pair_2,CUGA,CUGU +``` + +Run inference with either built-in model alias: + +```bash +uv run python -m src.infer --model manakov --input examples/mirna_pairs.csv --output predictions_manakov.csv +uv run python -m src.infer --model hejret --input examples/mirna_pairs.csv --output predictions_hejret.csv +``` + +The output CSV includes the original sequences, normalized sequences, alignment score, logistic probability, aligned sequences, alignment markers, and per-position operations. The default `--normalize auto` converts `U`/`T` as needed to match the trained model alphabet. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..bbd64fb --- /dev/null +++ b/examples/README.md @@ -0,0 +1,20 @@ +# Example Inference Inputs + +`mirna_pairs.csv` is a small ready-to-run input file for testing DiscrimAlign miRNA inference. + +Run it with a bundled trained model: + +```bash +uv run python -m src.infer \ + --model manakov \ + --input examples/mirna_pairs.csv \ + --output predictions_manakov.csv +``` + +The input schema is: + +- `id`: optional identifier preserved in the output +- `sequence_a`: first sequence +- `sequence_b`: second sequence + +The output adds probability, alignment score, normalized sequences, aligned sequences, alignment markers, and per-position operations. diff --git a/examples/mirna_pairs.csv b/examples/mirna_pairs.csv new file mode 100644 index 0000000..81bf54a --- /dev/null +++ b/examples/mirna_pairs.csv @@ -0,0 +1,5 @@ +id,sequence_a,sequence_b +example_positive_like,AUGCUA,AUGGUA +example_short,CUGA,CUGU +example_mismatch,AUGCUA,CUGU +example_dna_input,ATGCTA,ATGGTA diff --git a/src/__init__.py b/src/__init__.py index 1a23573..458e5fe 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1 +1,11 @@ from .discrimalign import discrimalign +from .inference import load_model, predict_csv, predict_pairs, save_model, summarize_alignment + +__all__ = [ + "discrimalign", + "load_model", + "predict_csv", + "predict_pairs", + "save_model", + "summarize_alignment", +] diff --git a/src/discrimalign.py b/src/discrimalign.py index c1f9d06..5d48d68 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -8,7 +8,7 @@ from scipy.optimize import minimize from copy import deepcopy from math import ceil -from .optimization import get_initial_estimate, get_first_alignment +from .optimization import create_powerstep, get_initial_estimate, get_first_alignment from .logit_link import logit_partial_scores, logit_logL, logit_subgradient @@ -91,6 +91,8 @@ def discrimalign(seqlistA, seqlistB, assert aligner_mode in {'local', 'global'} assert gap_mode in {'affine', 'linear'} assert substitution_mode in {'general', 'symmetric', 'simple'} + if stepfunction is None: + stepfunction = create_powerstep(1e-4) if alphabet is None: alphabet = _warm_start_alphabet(baseline_aligner, initial_parameters) if alphabet is None: diff --git a/src/infer.py b/src/infer.py new file mode 100644 index 0000000..afe9e89 --- /dev/null +++ b/src/infer.py @@ -0,0 +1,52 @@ +"""Command-line CSV inference for saved DiscrimAlign models.""" + +import argparse +from pathlib import Path + +from .inference import load_model, predict_csv + +MODEL_ALIASES = { + "manakov": Path("case_study_for_mirna/trained_models/manakov_best_model.pkl"), + "hejret": Path("case_study_for_mirna/trained_models/hejret_best_model.pkl"), +} + + +def _resolve_model_path(model): + return MODEL_ALIASES.get(model, Path(model)) + + +def main(argv=None): + parser = argparse.ArgumentParser(description="Run DiscrimAlign inference on sequence-pair CSV data.") + parser.add_argument( + "--model", + required=True, + help="Model path, or one of the built-in miRNA aliases: manakov, hejret.", + ) + parser.add_argument("--input", required=True, help="Input CSV containing sequence pairs.") + parser.add_argument("--output", required=True, help="Output CSV for predictions.") + parser.add_argument("--seq-a-column", default="sequence_a", help="Column name for first sequence.") + parser.add_argument("--seq-b-column", default="sequence_b", help="Column name for second sequence.") + parser.add_argument( + "--normalize", + default="auto", + choices=["auto", "none"], + help="Normalize U/T to match the trained model alphabet.", + ) + args = parser.parse_args(argv) + + model_path = _resolve_model_path(args.model) + model = load_model(model_path) + rows = predict_csv( + input_csv=args.input, + output_csv=args.output, + model=model, + sequence_a_column=args.seq_a_column, + sequence_b_column=args.seq_b_column, + normalize=args.normalize, + ) + print(f"Loaded model from {model_path}") + print(f"Wrote {len(rows)} prediction rows to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/src/inference.py b/src/inference.py new file mode 100644 index 0000000..f14532c --- /dev/null +++ b/src/inference.py @@ -0,0 +1,202 @@ +"""Inference helpers for trained DiscrimAlign models.""" + +import csv +import pickle +from pathlib import Path + +from .logit_link import logit_partial_scores +from .optimization import get_first_alignment + + +def _model_parts(model): + if not isinstance(model, dict): + raise TypeError("model must be a discrimalign result dictionary") + if "aligner" not in model: + raise ValueError("model must contain an 'aligner' entry") + if "alpha" in model: + alpha = model["alpha"] + elif isinstance(model.get("summary"), dict) and "alpha" in model["summary"]: + alpha = model["summary"]["alpha"] + else: + raise ValueError("model must contain 'alpha' or 'summary[alpha]' entries") + return model["aligner"], alpha + + +def _model_alphabet(aligner): + matrix = getattr(aligner, "substitution_matrix", None) + alphabet = getattr(matrix, "alphabet", None) + if alphabet is None: + return None + return set(alphabet) + + +def _normalize_sequence(sequence, alphabet, normalize): + if normalize in (None, False, "none"): + return sequence + if normalize != "auto": + raise ValueError("normalize must be 'auto', 'none', None, or False") + if alphabet and "T" in alphabet and "U" not in alphabet: + return sequence.replace("U", "T").replace("u", "t") + if alphabet and "U" in alphabet and "T" not in alphabet: + return sequence.replace("T", "U").replace("t", "u") + return sequence + + +def _alignment_rows(alignment): + target = str(alignment[0]) + query = str(alignment[1]) + markers = [] + operations = [] + for target_char, query_char in zip(target, query): + if target_char == "-" or query_char == "-": + marker = "-" + operation = "gap" + elif target_char == query_char: + marker = "|" + operation = "match" + else: + marker = "." + operation = "mismatch" + markers.append(marker) + operations.append(operation) + return target, "".join(markers), query, operations + + +def summarize_alignment(alignment): + """Return a serializable summary of a Biopython alignment.""" + target, markers, query, operations = _alignment_rows(alignment) + return { + "aligned_sequence_a": target, + "alignment_marks": markers, + "aligned_sequence_b": query, + "operations": operations, + "text": str(alignment), + } + + +def predict_pairs(seqlistA, seqlistB, model, return_alignments=True, normalize="auto"): + """Score sequence pairs with a fitted DiscrimAlign model. + + Parameters + ---------- + seqlistA, seqlistB : iterable of str + Sequence pairs to align and score. + model : dict + Result dictionary returned by ``discrimalign``. + return_alignments : bool, default=True + Include text and per-position alignment summaries in each row. + normalize : {"auto", "none"}, default="auto" + Convert U/T automatically when the fitted model alphabet requires it. + """ + seqlistA = list(seqlistA) + seqlistB = list(seqlistB) + if len(seqlistA) != len(seqlistB): + raise ValueError("seqlistA and seqlistB must have the same length") + + aligner, alpha = _model_parts(model) + alphabet = _model_alphabet(aligner) + rows = [] + for index, (seqA, seqB) in enumerate(zip(seqlistA, seqlistB)): + normalized_seqA = _normalize_sequence(seqA, alphabet, normalize) + normalized_seqB = _normalize_sequence(seqB, alphabet, normalize) + alignment = get_first_alignment(normalized_seqA, normalized_seqB, aligner) + probability = float(logit_partial_scores([alignment.score], alpha)[0]) + row = { + "index": index, + "sequence_a": seqA, + "sequence_b": seqB, + "normalized_sequence_a": normalized_seqA, + "normalized_sequence_b": normalized_seqB, + "alignment_score": float(alignment.score), + "probability": probability, + } + if return_alignments: + row.update(summarize_alignment(alignment)) + rows.append(row) + return rows + + +def save_model(model, path): + """Save a fitted DiscrimAlign result dictionary for later inference.""" + _model_parts(model) + path = Path(path) + with path.open("wb") as output_file: + pickle.dump(model, output_file) + + +def load_model(path): + """Load a fitted DiscrimAlign result dictionary saved with ``save_model``.""" + path = Path(path) + with path.open("rb") as input_file: + model = pickle.load(input_file) + _model_parts(model) + return model + + +def predict_csv( + input_csv, + output_csv, + model, + sequence_a_column="sequence_a", + sequence_b_column="sequence_b", + normalize="auto", +): + """Run inference from a CSV file and write prediction rows to another CSV.""" + input_csv = Path(input_csv) + output_csv = Path(output_csv) + with input_csv.open(newline="") as input_file: + reader = csv.DictReader(input_file) + if reader.fieldnames is None: + raise ValueError("input CSV must include a header row") + missing_columns = { + column + for column in (sequence_a_column, sequence_b_column) + if column not in reader.fieldnames + } + if missing_columns: + missing = ", ".join(sorted(missing_columns)) + raise ValueError(f"input CSV is missing required columns: {missing}") + input_rows = list(reader) + + predictions = predict_pairs( + [row[sequence_a_column] for row in input_rows], + [row[sequence_b_column] for row in input_rows], + model, + normalize=normalize, + ) + output_rows = [] + for input_row, prediction in zip(input_rows, predictions): + output_rows.append( + { + **input_row, + "normalized_sequence_a": prediction["normalized_sequence_a"], + "normalized_sequence_b": prediction["normalized_sequence_b"], + "alignment_score": prediction["alignment_score"], + "probability": prediction["probability"], + "aligned_sequence_a": prediction["aligned_sequence_a"], + "alignment_marks": prediction["alignment_marks"], + "aligned_sequence_b": prediction["aligned_sequence_b"], + "operations": ";".join(prediction["operations"]), + } + ) + + fieldnames = list(input_rows[0].keys()) if input_rows else list(reader.fieldnames) + for fieldname in ( + "normalized_sequence_a", + "normalized_sequence_b", + "alignment_score", + "probability", + "aligned_sequence_a", + "alignment_marks", + "aligned_sequence_b", + "operations", + ): + if fieldname not in fieldnames: + fieldnames.append(fieldname) + + output_csv.parent.mkdir(parents=True, exist_ok=True) + with output_csv.open("w", newline="") as output_file: + writer = csv.DictWriter(output_file, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(output_rows) + return output_rows diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py new file mode 100644 index 0000000..84e417e --- /dev/null +++ b/tests/test_discrimalign.py @@ -0,0 +1,22 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.discrimalign import discrimalign + + +def test_discrimalign_uses_default_stepfunction(): + result = discrimalign( + seqlistA=["AUGCUA", "CUGA"], + seqlistB=["AUGGUA", "CUGU"], + labels=[1, 0], + aligner_mode="local", + gap_mode="affine", + substitution_mode="symmetric", + max_iter=1, + num_threads=1, + ) + + assert "final_loglik" in result + assert "alpha" in result diff --git a/tests/test_inference.py b/tests/test_inference.py new file mode 100644 index 0000000..f66da20 --- /dev/null +++ b/tests/test_inference.py @@ -0,0 +1,68 @@ +import csv +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.discrimalign import discrimalign +from src.inference import load_model, predict_csv, predict_pairs, save_model + + +def _fit_model(): + return discrimalign( + seqlistA=["AUGCUA", "CUGA"], + seqlistB=["AUGGUA", "CUGU"], + labels=[1, 0], + max_iter=1, + num_threads=1, + ) + + +def test_predict_pairs_returns_serializable_alignment_rows(): + rows = predict_pairs(["AUGCUA"], ["AUGGUA"], _fit_model()) + + assert rows[0]["index"] == 0 + assert rows[0]["sequence_a"] == "AUGCUA" + assert rows[0]["sequence_b"] == "AUGGUA" + assert 0.0 <= rows[0]["probability"] <= 1.0 + assert rows[0]["aligned_sequence_a"] + assert rows[0]["alignment_marks"] + assert rows[0]["aligned_sequence_b"] + assert len(rows[0]["operations"]) == len(rows[0]["alignment_marks"]) + + +def test_predict_pairs_validates_pair_counts(): + try: + predict_pairs(["A"], ["A", "C"], _fit_model()) + except ValueError as exc: + assert "same length" in str(exc) + else: + raise AssertionError("Expected pair-count validation error") + + +def test_save_load_model_and_predict_csv(tmp_path): + model_path = tmp_path / "model.pkl" + input_path = tmp_path / "pairs.csv" + output_path = tmp_path / "predictions.csv" + + save_model(_fit_model(), model_path) + model = load_model(model_path) + + input_path.write_text("id,sequence_a,sequence_b\nexample,AUGCUA,AUGGUA\n") + rows = predict_csv(input_path, output_path, model) + + assert len(rows) == 1 + with output_path.open(newline="") as output_file: + output_rows = list(csv.DictReader(output_file)) + assert output_rows[0]["id"] == "example" + assert output_rows[0]["probability"] + assert output_rows[0]["alignment_marks"] + + +def test_load_model_supports_case_study_schema(): + model = load_model("case_study_for_mirna/trained_models/manakov_best_model.pkl") + rows = predict_pairs(["ATGCTA"], ["ATGGTA"], model) + + assert len(rows) == 1 + assert 0.0 <= rows[0]["probability"] <= 1.0 + assert rows[0]["alignment_marks"] From 0de90e5d1689d19d339be65bcbecc886aafa0bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Thu, 1 Oct 2026 23:21:25 +0200 Subject: [PATCH 37/65] Require nwgrad>=0.5.0; CI tests the released wheel --- .github/workflows/tests.yml | 9 ++------- TODO-nwgrad.md | 11 ----------- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 89eb980..402761f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,16 +7,11 @@ on: jobs: pytest: runs-on: ubuntu-latest - env: - # Until nwgrad 0.5.0 is released, test against this nwgrad commit. - NWGRAD_REV: eeba40acc2cff7b244617355511e049a5815a108 steps: - uses: actions/checkout@v5 - uses: astral-sh/setup-uv@v10.2.0 with: python-version: "3.12" - run: uv sync - - run: | - uv pip install "nwgrad @ git+https://github.com/michalsta/nwgrad@${NWGRAD_REV}" - uv pip show nwgrad - - run: uv run --no-sync pytest + - run: uv pip show nwgrad + - run: uv run pytest diff --git a/TODO-nwgrad.md b/TODO-nwgrad.md index 2b1919b..e916c74 100644 --- a/TODO-nwgrad.md +++ b/TODO-nwgrad.md @@ -3,17 +3,6 @@ Items deliberately left out of the nwgrad backend work, roughly in the order they become relevant. -## At the nwgrad 0.5.0 release - -- **Dependency:** in `pyproject.toml`, require `nwgrad>=0.5.0`. - `SeqPairBatch.scores()` and `SeqPairBatch.weighted_grad()` first appear in - 0.5.0. -- **CI:** in `.github/workflows/tests.yml`, remove the step that installs - nwgrad from a pinned GitHub commit (`NWGRAD_REV`), so CI tests the released - wheel, and confirm one green run against it. -- Until then, a local `uv sync` installs nwgrad 0.4.0 from PyPI, which lacks - the API this code uses. - ## Validation - **Real data:** run both backends on the miRNA case study and compare the diff --git a/pyproject.toml b/pyproject.toml index 763f873..bba2418 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "jupyterlab>=4.2.0", "matplotlib>=3.9.2", "mirbench", - "nwgrad>=0.4.0", + "nwgrad>=0.5.0", "numpy>=1.26.4", "pandas>=2.2.2", "scikit-learn>=1.4.2", From 776bb5a959ac10c32dcef80ac1a2f6e7dd67e043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 00:09:53 +0200 Subject: [PATCH 38/65] Build the initial-estimate feature matrix as one numpy array --- src/discrimalign.py | 2 +- src/nwgrad_backend.py | 24 ++++++++++- src/nwgrad_params.py | 17 ++++++-- src/optimization.py | 82 +++++++++++++++++++++++++---------- tests/test_nwgrad_backend.py | 21 +++++++++ tests/test_optimization.py | 83 +++++++++++++++++++++++++++++++++++- 6 files changed, 199 insertions(+), 30 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 07af270..441ef01 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -250,7 +250,7 @@ def add_noise(shape, niter): engine.set_params(baseline_parameters(aligner, gap_mode, alphabet), substitution_mode='general') engine.scores() - updated_parameters = get_initial_estimate_from_counts(engine.raw_counts(), labels, + updated_parameters = get_initial_estimate_from_counts(engine.count_arrays(), labels, substitution_mode=substitution_mode, gap_mode=gap_mode, alphabet=alphabet) diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index a77251b..cb36445 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -7,7 +7,8 @@ import nwgrad from Bio.Align import substitution_matrices -from .nwgrad_params import grad_to_raw, to_nwgrad +from .nwgrad_params import gap_counts, grad_to_raw, to_nwgrad +from .optimization import CountArrays def baseline_parameters(aligner, gap_mode, alphabet): @@ -94,6 +95,27 @@ def raw_counts(self): """Per-pair counts in logit_subgradient's format, for the scores just computed.""" return [grad_to_raw(self.batch[i].grad) for i in range(len(self.batch))] + def count_arrays(self): + """ + The same per-pair counts as raw_counts(), as CountArrays, without + building a dict and a substitution matrix object per pair. + """ + n = len(self.batch) + substitutions = None + gap_opens = np.empty(n) + gap_extends = np.empty(n) + for i in range(n): + grad = self.batch[i].grad.to_dict() + if substitutions is None: + alphabet = grad['alphabet'] + substitutions = np.empty((n, len(alphabet), len(alphabet))) + substitutions[i] = grad['matrix'] + gap_opens[i], gap_extends[i] = gap_counts(grad) + if substitutions is None: + alphabet = self.alphabet + substitutions = np.empty((0, len(alphabet), len(alphabet))) + return CountArrays(substitutions, gap_opens, gap_extends, alphabet) + def raw_subgradient(self, logit_scores, labels, alpha): """ sum_i (label_i - logit_score_i) * counts_i, in logit_subgradient's diff --git a/src/nwgrad_params.py b/src/nwgrad_params.py index 3aa0bad..d1e9858 100644 --- a/src/nwgrad_params.py +++ b/src/nwgrad_params.py @@ -68,10 +68,19 @@ def grad_to_raw(grad): """ if hasattr(grad, 'to_dict'): grad = grad.to_dict() - gap_open = grad['gap_open_a'] + grad['gap_open_b'] - gap_extend = grad['gap_extend_a'] + grad['gap_extend_b'] + gap_opens, gap_extends = gap_counts(grad) substitutions = substitution_matrices.Array(alphabet=grad['alphabet'], data=np.array(grad['matrix'], dtype=float)) return {'Substitutions': substitutions, - 'Gap opens': -gap_open, - 'Gap extends': gap_open - gap_extend} + 'Gap opens': gap_opens, + 'Gap extends': gap_extends} + + +def gap_counts(grad): + """ + (gap opens, gap extends) in logit_subgradient's sense, from an nwgrad + gradient's to_dict(). See grad_to_raw(). + """ + gap_open = grad['gap_open_a'] + grad['gap_open_b'] + gap_extend = grad['gap_extend_a'] + grad['gap_extend_b'] + return -gap_open, gap_open - gap_extend diff --git a/src/optimization.py b/src/optimization.py index 576964d..ba63af3 100644 --- a/src/optimization.py +++ b/src/optimization.py @@ -1,3 +1,5 @@ +from typing import NamedTuple + import numpy as np from sklearn.linear_model import LogisticRegression from Bio.Align import substitution_matrices @@ -63,27 +65,63 @@ def _alignment_features(alignment_list, substitution_mode, gap_mode, alphabet): return predictors -def _count_features(raw_counts_list, substitution_mode, gap_mode, alphabet): +class CountArrays(NamedTuple): """ - The same predictors as _alignment_features(), from per-alignment counts in - logit_subgradient's format ({'Substitutions', 'Gap opens', 'Gap extends'}), - such as nwgrad's per-pair gradients converted by grad_to_raw(). In linear - mode only the total number of gap columns is used, which is all that - nwgrad reports there. + Per-alignment counts in logit_subgradient's sense, one row per alignment: + substitutions (N, A, A) with rows and columns in the order of alphabet, + and gap_opens and gap_extends (N,). """ - predictors = [] - for raw in raw_counts_list: + substitutions: np.ndarray + gap_opens: np.ndarray + gap_extends: np.ndarray + alphabet: str + + +def _count_arrays_from_raw(raw_counts_list): + """ + CountArrays from a list of counts in logit_subgradient's format, in the + alphabet order of the first one. + """ + n = len(raw_counts_list) + alphabet = ''.join(raw_counts_list[0]['Substitutions'].alphabet) if n else '' + substitutions = np.empty((n, len(alphabet), len(alphabet))) + gap_opens = np.empty(n) + gap_extends = np.empty(n) + for i, raw in enumerate(raw_counts_list): subs = raw['Substitutions'] - G = np.asarray(subs) - opens, extends = raw['Gap opens'], raw['Gap extends'] - gaps = [opens, extends] if gap_mode == 'affine' else [opens + extends] - if substitution_mode == 'simple': - predictors.append([np.trace(G), G.sum() - np.trace(G)] + gaps) - else: - # Rows and columns in the order of alphabet, whatever the order of subs. - order = [subs.alphabet.index(char) for char in alphabet] - predictors.append(list(G[np.ix_(order, order)].ravel()) + gaps) - return predictors + order = [subs.alphabet.index(char) for char in alphabet] + substitutions[i] = np.asarray(subs)[np.ix_(order, order)] + gap_opens[i] = raw['Gap opens'] + gap_extends[i] = raw['Gap extends'] + return CountArrays(substitutions, gap_opens, gap_extends, alphabet) + + +def _count_features(counts, substitution_mode, gap_mode, alphabet): + """ + The same predictors as _alignment_features(), as an (N, p) array, from + per-alignment counts: CountArrays, or a list in logit_subgradient's format + ({'Substitutions', 'Gap opens', 'Gap extends'}), such as nwgrad's per-pair + gradients converted by grad_to_raw(). In linear mode only the total number + of gap columns is used, which is all that nwgrad reports there. + """ + if not isinstance(counts, CountArrays): + counts = _count_arrays_from_raw(counts) + S = counts.substitutions + gaps = ([counts.gap_opens, counts.gap_extends] if gap_mode == 'affine' + else [counts.gap_opens + counts.gap_extends]) + if substitution_mode == 'simple': + matches = np.trace(S, axis1=1, axis2=2) + columns = [matches, S.sum(axis=(1, 2)) - matches] + gaps + return np.column_stack(columns) if len(S) else np.empty((0, len(columns))) + # Rows and columns in the order of alphabet, whatever the order of the counts. + if alphabet != counts.alphabet: + order = [counts.alphabet.index(char) for char in alphabet] + S = S[:, order][:, :, order] + features = np.empty((len(S), len(alphabet)**2 + len(gaps))) + features[:, :len(alphabet)**2] = S.reshape(len(S), -1) + for k, column in enumerate(gaps, start=len(alphabet)**2): + features[:, k] = column + return features def _fit_initial_estimate(predictors, labels, substitution_mode, gap_mode, alphabet): @@ -142,16 +180,16 @@ def get_initial_estimate(alignment_list, labels, return _fit_initial_estimate(predictors, labels, substitution_mode, gap_mode, alphabet) -def get_initial_estimate_from_counts(raw_counts_list, labels, +def get_initial_estimate_from_counts(counts, labels, substitution_mode='simple', gap_mode='affine', alphabet=None): """ - get_initial_estimate() from per-alignment counts in logit_subgradient's - format instead of alignment objects. + get_initial_estimate() from per-alignment counts instead of alignment + objects: CountArrays, or a list in logit_subgradient's format. """ _check_estimate_modes(substitution_mode, gap_mode, alphabet) - predictors = _count_features(raw_counts_list, substitution_mode, gap_mode, alphabet) + predictors = _count_features(counts, substitution_mode, gap_mode, alphabet) return _fit_initial_estimate(predictors, labels, substitution_mode, gap_mode, alphabet) ### Parallel processing diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index 58485b9..9e44255 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -111,6 +111,27 @@ def test_nwgrad_initial_estimate_is_fitted_on_nwgrad_counts(mode, gap_mode, subs assert res["alpha"] == pytest.approx(expected["alpha"], rel=1e-12) +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_count_arrays_equal_raw_counts(mode, gap_mode, substitution_mode): + """count_arrays() gives exactly the per-pair counts of raw_counts().""" + from src.nwgrad_backend import NwgradEngine + from src.optimization import _count_arrays_from_raw + rng, A, B, _ = _data(5, n=20) + engine = NwgradEngine(A, B, mode, gap_mode, substitution_mode, "ACGT", 2) + baseline = baseline_parameters(default_baseline(mode, gap_mode, substitution_mode), + gap_mode, "ACGT") + for params, params_mode in ((baseline, "general"), + (random_params(rng, gap_mode, substitution_mode), None)): + engine.set_params(params, substitution_mode=params_mode) + engine.scores() + counts = engine.count_arrays() + expected = _count_arrays_from_raw(engine.raw_counts()) + assert counts.alphabet == expected.alphabet + for field in ("substitutions", "gap_opens", "gap_extends"): + np.testing.assert_array_equal(getattr(counts, field), getattr(expected, field), + err_msg=field) + + @pytest.mark.parametrize("initial", [True, False]) def test_nwgrad_backend_needs_no_biopython_alignment(initial, monkeypatch): """Without returned alignments, the nwgrad path never aligns with Biopython.""" diff --git a/tests/test_optimization.py b/tests/test_optimization.py index 31cb58a..eff3edd 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -3,14 +3,15 @@ import numpy as np import pytest -from Bio.Align import PairwiseAligner +from Bio.Align import PairwiseAligner, substitution_matrices from joblib import Parallel from scipy.special import expit from src.optimization import (EmptyAlignmentCounts, EmptyLocalAlignment, + _count_arrays_from_raw, _count_features, _fit_initial_estimate, create_alignment_workers, create_constant_step, create_powerstep, get_first_alignment, - get_initial_estimate) + get_initial_estimate, get_initial_estimate_from_counts) from tests.helpers import DNA, align_all, make_aligner, make_pairs, random_params @@ -325,3 +326,81 @@ def test_initial_estimate_accepts_empty_local_alignments(): warnings.simplefilter("ignore") est = get_initial_estimate(alns, labels, "general", "affine", "ACGT") assert np.all(np.isfinite(np.asarray(est["substitution_matrix"]))) + + +# --- count features ---------------------------------------------------------- + +def _reference_count_features(raw_counts_list, substitution_mode, gap_mode, alphabet): + """The per-row implementation that _count_features() replaced.""" + predictors = [] + for raw in raw_counts_list: + subs = raw['Substitutions'] + G = np.asarray(subs) + opens, extends = raw['Gap opens'], raw['Gap extends'] + gaps = [opens, extends] if gap_mode == 'affine' else [opens + extends] + if substitution_mode == 'simple': + predictors.append([np.trace(G), G.sum() - np.trace(G)] + gaps) + else: + order = [subs.alphabet.index(char) for char in alphabet] + predictors.append(list(G[np.ix_(order, order)].ravel()) + gaps) + return predictors + + +def _random_raw_counts(rng, n, count_alphabet): + """Counts as grad_to_raw() returns them, over count_alphabet.""" + k = len(count_alphabet) + return [{'Substitutions': substitution_matrices.Array( + alphabet=count_alphabet, data=rng.poisson(1.5, (k, k)).astype(float)), + 'Gap opens': float(rng.poisson(1)), + 'Gap extends': float(rng.poisson(2))} for _ in range(n)] + + +# Counts over the fit's alphabet, a permutation of it, and a superset of it. +COUNT_ALPHABETS = [DNA, "TGCA", "ACGTN"] + + +@pytest.mark.parametrize("count_alphabet", COUNT_ALPHABETS) +@pytest.mark.parametrize("gap_mode", ["affine", "linear"]) +@pytest.mark.parametrize("substitution_mode", ["simple", "symmetric", "general"]) +def test_count_features_match_per_row_reference(substitution_mode, gap_mode, count_alphabet): + raws = _random_raw_counts(np.random.default_rng(0), 40, count_alphabet) + expected = np.asarray(_reference_count_features(raws, substitution_mode, gap_mode, DNA), + dtype=float) + for counts in (raws, _count_arrays_from_raw(raws)): + features = _count_features(counts, substitution_mode, gap_mode, DNA) + assert features.dtype == np.float64 + np.testing.assert_array_equal(features, expected) + + +def test_count_arrays_from_raw_reorders_to_the_first_alphabet(): + rng = np.random.default_rng(1) + raws = _random_raw_counts(rng, 3, DNA) + _random_raw_counts(rng, 2, "TGCA") + counts = _count_arrays_from_raw(raws) + assert counts.alphabet == DNA + for i, raw in enumerate(raws): + for a, x in enumerate(DNA): + for b, z in enumerate(DNA): + assert counts.substitutions[i, a, b] == raw['Substitutions'][x, z] + np.testing.assert_array_equal(counts.gap_opens, [r['Gap opens'] for r in raws]) + np.testing.assert_array_equal(counts.gap_extends, [r['Gap extends'] for r in raws]) + + +@pytest.mark.parametrize("gap_mode", ["affine", "linear"]) +@pytest.mark.parametrize("substitution_mode", ["simple", "symmetric", "general"]) +def test_initial_estimate_from_counts_is_unchanged(substitution_mode, gap_mode): + rng = np.random.default_rng(2) + raws = _random_raw_counts(rng, 60, "TGCA") + labels = rng.integers(0, 2, len(raws)) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + reference = _fit_initial_estimate( + _reference_count_features(raws, substitution_mode, gap_mode, DNA), + labels, substitution_mode, gap_mode, DNA) + from_list = get_initial_estimate_from_counts(raws, labels, substitution_mode, gap_mode, DNA) + from_arrays = get_initial_estimate_from_counts(_count_arrays_from_raw(raws), labels, + substitution_mode, gap_mode, DNA) + for estimate in (from_list, from_arrays): + assert estimate.keys() == reference.keys() + for key in reference: + np.testing.assert_array_equal(np.asarray(estimate[key]), np.asarray(reference[key]), + err_msg=key) From 84404fc2f8fbf73df4569a0812359f5b15b24da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 00:37:53 +0200 Subject: [PATCH 39/65] Read per-pair counts with SeqPairBatch.grads() where nwgrad has it --- src/nwgrad_backend.py | 18 ++++++++++++++---- src/nwgrad_params.py | 15 ++++++++++----- tests/test_nwgrad_backend.py | 14 +++++++++++--- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index cb36445..0cf0ee3 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -7,7 +7,7 @@ import nwgrad from Bio.Align import substitution_matrices -from .nwgrad_params import gap_counts, grad_to_raw, to_nwgrad +from .nwgrad_params import GAP_FIELDS, gap_counts, grad_to_raw, to_nwgrad from .optimization import CountArrays @@ -97,9 +97,19 @@ def raw_counts(self): def count_arrays(self): """ - The same per-pair counts as raw_counts(), as CountArrays, without - building a dict and a substitution matrix object per pair. + The same per-pair counts as raw_counts(), as CountArrays: from one + SeqPairBatch.grads() call where nwgrad has it, else pair by pair. """ + if hasattr(self.batch, 'grads') and len(self.batch): + return self._count_arrays_bulk() + return self._count_arrays_per_pair() + + def _count_arrays_bulk(self): + matrices, gaps = self.batch.grads() + gap_opens, gap_extends = gap_counts(*gaps.T) + return CountArrays(matrices, gap_opens, gap_extends, self.batch.alphabet) + + def _count_arrays_per_pair(self): n = len(self.batch) substitutions = None gap_opens = np.empty(n) @@ -110,7 +120,7 @@ def count_arrays(self): alphabet = grad['alphabet'] substitutions = np.empty((n, len(alphabet), len(alphabet))) substitutions[i] = grad['matrix'] - gap_opens[i], gap_extends[i] = gap_counts(grad) + gap_opens[i], gap_extends[i] = gap_counts(*(grad[field] for field in GAP_FIELDS)) if substitutions is None: alphabet = self.alphabet substitutions = np.empty((0, len(alphabet), len(alphabet))) diff --git a/src/nwgrad_params.py b/src/nwgrad_params.py index d1e9858..441931f 100644 --- a/src/nwgrad_params.py +++ b/src/nwgrad_params.py @@ -68,7 +68,7 @@ def grad_to_raw(grad): """ if hasattr(grad, 'to_dict'): grad = grad.to_dict() - gap_opens, gap_extends = gap_counts(grad) + gap_opens, gap_extends = gap_counts(*(grad[field] for field in GAP_FIELDS)) substitutions = substitution_matrices.Array(alphabet=grad['alphabet'], data=np.array(grad['matrix'], dtype=float)) return {'Substitutions': substitutions, @@ -76,11 +76,16 @@ def grad_to_raw(grad): 'Gap extends': gap_extends} -def gap_counts(grad): +# nwgrad's gap fields, in the column order of SeqPairBatch.grads(). +GAP_FIELDS = ('gap_open_a', 'gap_extend_a', 'gap_open_b', 'gap_extend_b') + + +def gap_counts(gap_open_a, gap_extend_a, gap_open_b, gap_extend_b): """ (gap opens, gap extends) in logit_subgradient's sense, from an nwgrad - gradient's to_dict(). See grad_to_raw(). + gradient's gap fields: numbers, or arrays with one entry per pair. See + grad_to_raw(). """ - gap_open = grad['gap_open_a'] + grad['gap_open_b'] - gap_extend = grad['gap_extend_a'] + grad['gap_extend_b'] + gap_open = gap_open_a + gap_open_b + gap_extend = gap_extend_a + gap_extend_b return -gap_open, gap_open - gap_extend diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index 9e44255..98df22b 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -9,6 +9,7 @@ import warnings import numpy as np +import nwgrad import pytest from Bio.Align import PairwiseAligner, substitution_matrices @@ -111,9 +112,16 @@ def test_nwgrad_initial_estimate_is_fitted_on_nwgrad_counts(mode, gap_mode, subs assert res["alpha"] == pytest.approx(expected["alpha"], rel=1e-12) +NWGRAD_HAS_GRADS = hasattr(nwgrad.SeqPairBatchDouble, "grads") + + +@pytest.mark.parametrize("path", [ + "count_arrays", "_count_arrays_per_pair", + pytest.param("_count_arrays_bulk", marks=pytest.mark.skipif( + not NWGRAD_HAS_GRADS, reason="nwgrad without SeqPairBatch.grads()"))]) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_count_arrays_equal_raw_counts(mode, gap_mode, substitution_mode): - """count_arrays() gives exactly the per-pair counts of raw_counts().""" +def test_count_arrays_equal_raw_counts(mode, gap_mode, substitution_mode, path): + """count_arrays(), by either path, gives exactly the per-pair counts of raw_counts().""" from src.nwgrad_backend import NwgradEngine from src.optimization import _count_arrays_from_raw rng, A, B, _ = _data(5, n=20) @@ -124,7 +132,7 @@ def test_count_arrays_equal_raw_counts(mode, gap_mode, substitution_mode): (random_params(rng, gap_mode, substitution_mode), None)): engine.set_params(params, substitution_mode=params_mode) engine.scores() - counts = engine.count_arrays() + counts = getattr(engine, path)() expected = _count_arrays_from_raw(engine.raw_counts()) assert counts.alphabet == expected.alphabet for field in ("substitutions", "gap_opens", "gap_extends"): From bdbae665e90714563f66f7bc15cbfd03528dbc97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 00:43:11 +0200 Subject: [PATCH 40/65] TODO-nwgrad: bulk per-pair gradients are on nwgrad main; require them at the next release --- TODO-nwgrad.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/TODO-nwgrad.md b/TODO-nwgrad.md index e916c74..bdd0fe1 100644 --- a/TODO-nwgrad.md +++ b/TODO-nwgrad.md @@ -56,9 +56,12 @@ by making both backends break such ties the same way. tables stay in cache. See `TODO.md` in nwgrad. DiscrimAlign therefore resolves `num_threads=0` to `os.cpu_count()` itself; long-sequence (protein) workloads may do better with physical cores (TODO in `discrimalign()`). -- **Per-pair gradients in bulk.** The initial estimate reads every pair's - gradient through `batch[i].grad.to_dict()`, about 1.4 s per 50,000 pairs. A - batch call returning all per-pair gradients as one array would remove that. +- **Per-pair gradients in bulk.** nwgrad `main` has `SeqPairBatch.grads()` + (`c159946`), which returns all per-pair gradients as two arrays. The + initial estimate uses it when the installed nwgrad has it, and otherwise + falls back to reading `batch[i].grad.to_dict()` pair by pair (about 5x + slower). At the next nwgrad release, require it and remove + `NwgradEngine._count_arrays_per_pair()`. - **float32.** The backend uses nwgrad's double-precision classes with pointer traceback. The float32 classes would be faster, but nwgrad's default float32 traceback can return a suboptimal path, and Biopython works in double. From d9b214dffe60fa31a79b697905fe8bc2b5e19d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 08:32:54 +0200 Subject: [PATCH 41/65] Fit alpha with a safeguarded Newton method by default; keep BFGS as alpha_solver='bfgs' --- README.md | 10 +++++ TODO-nwgrad.md | 5 --- src/discrimalign.py | 44 ++++++++++++---------- src/logit_link.py | 75 +++++++++++++++++++++++++++++++++++++ tests/test_discrimalign.py | 77 ++++++++++++++++++++++++++++++++++---- tests/test_logit_link.py | 53 +++++++++++++++++++++++++- 6 files changed, 231 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 40d22b9..a7c24ba 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,15 @@ uv run python -m src.infer --model model.pkl --input examples/mirna_pairs.csv -- `num_threads=0`, the default, chooses the thread count automatically: all logical cores with the nwgrad backend, and one thread with the Biopython backend, whose threads contend for Python's global interpreter lock and only slow it down. Any other value is used as given. For long sequences such as full-length proteins, the number of physical cores can be faster than all logical cores; pass it explicitly. +### Intercept fit + +At every iteration the intercept α is refitted to the current alignment scores. `alpha_solver` selects how: + +- `"safeguarded_newton"` (default): Newton's method on dL/dα, which is strictly decreasing in α, so its root is the unique optimum. Plain Newton steps are used while they are small, as they are when α changes little between iterations; otherwise the root is bracketed and Newton steps are combined with bisection. The result is exact to rounding from any starting value. +- `"bfgs"`: the previous `scipy.optimize.minimize` fit. When the optimum moves far between iterations, as with `subgradient_scale=1` on large datasets, it can stop far from the optimum. + +On all 2.5 million Manakov training pairs, 300 iterations took 18.4 minutes with the default against 29.6 minutes with `"bfgs"`, and gave the same fit. + ### Backends `backend` selects where alignment scores and subgradients come from: @@ -246,6 +255,7 @@ Compared with earlier versions of DiscrimAlign: - In `symmetric` and `general` substitution mode with linear gaps, the initial estimate fits one coefficient on the number of gap columns. It previously added the gap-open and gap-extend coefficients of an affine fit. - Empty sequences raise a `ValueError` before any alignment work. - The default backend is nwgrad, and `num_threads` defaults to automatic. +- The intercept α is fitted exactly by a safeguarded Newton method (`alpha_solver="safeguarded_newton"`); the previous BFGS fit is available as `alpha_solver="bfgs"`. With labels of only one class, fitting α now raises a `ValueError`, since the likelihood then has no finite maximum. ## Running tests diff --git a/TODO-nwgrad.md b/TODO-nwgrad.md index bdd0fe1..311e997 100644 --- a/TODO-nwgrad.md +++ b/TODO-nwgrad.md @@ -23,11 +23,6 @@ they become relevant. `log1p(-p)` from `p = expit(z)`, which loses precision on confident predictions, and clips each pair's loss at about 36. A stable form computes `sum(y*z - logaddexp(0, z))` from the logits. -- **The intercept fit** (`scipy.optimize.minimize`, default BFGS tolerance) - leaves α about 1e-4 from the optimum where the likelihood is flat. It is - also the largest per-iteration cost at high thread counts: the alignment - itself drops to under half of an iteration. A one-dimensional Newton or - bracketing solve on dL/dα would be exact and cheaper. - **`src.discrimalign` is shadowed:** `src/__init__.py` imports the function under the module's name, so `import src.discrimalign` binds the function. Use `sys.modules["src.discrimalign"]` to reach the module. diff --git a/src/discrimalign.py b/src/discrimalign.py index b23f4c6..ff21b54 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -11,7 +11,7 @@ import os from .optimization import (create_powerstep, get_initial_estimate, get_initial_estimate_from_counts, get_first_alignment) -from .logit_link import logit_partial_scores, logit_logL, logit_subgradient +from .logit_link import fit_alpha, logit_partial_scores, logit_logL, logit_subgradient def _align_pair_chunk(pair_chunk, aligner): @@ -149,7 +149,8 @@ def discrimalign(seqlistA, seqlistB, initial_parameters=None, return_alignments=True, verbose=False, - backend='nwgrad'): + backend='nwgrad', + alpha_solver='safeguarded_newton'): """ backend selects where alignment scores and subgradients come from: 'nwgrad' (the default) or 'biopython' (PairwiseAligner). With 'nwgrad', the initial @@ -160,9 +161,16 @@ def discrimalign(seqlistA, seqlistB, num_threads=0 (the default) picks the thread count automatically: all logical cores with 'nwgrad', 1 with 'biopython', whose threads contend for the GIL and only slow it down. + + alpha_solver selects how the intercept alpha is fitted at each iteration: + 'safeguarded_newton' (the default) finds the exact optimum with fit_alpha() + in logit_link; 'bfgs' is the previous scipy.optimize.minimize (BFGS) fit, + which can stop far from the optimum when alpha moves a long way between + iterations, e.g. with subgradient_scale=1 on large data. """ # TODO: Implement tol and additional stepfunctions. assert backend in {'biopython', 'nwgrad'} + assert alpha_solver in {'safeguarded_newton', 'bfgs'} assert aligner_mode in {'local', 'global'} assert gap_mode in {'affine', 'linear'} assert substitution_mode in {'general', 'symmetric', 'simple'} @@ -291,24 +299,20 @@ def add_noise(shape, niter): ## loglik_sd.append(SDL) # Optimize the logistic intercept (alpha) - def alpha_target(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return -logit_logL(logit_scores, labels) - - def alpha_fprime(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return -np.sum(labels - logit_scores) - - def alpha_fsec(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return np.sum(logit_scores*(1 - logit_scores)) - - new_alpha = minimize(alpha_target, - updated_parameters['alpha'], - jac=alpha_fprime, - # hess=alpha_fsec, - # method='Newton-CG' - )['x'][0] + if alpha_solver == 'safeguarded_newton': + new_alpha = fit_alpha(alignment_scores, labels, updated_parameters['alpha']) + else: + def alpha_target(alpha): + logit_scores = logit_partial_scores(alignment_scores, alpha) + return -logit_logL(logit_scores, labels) + + def alpha_fprime(alpha): + logit_scores = logit_partial_scores(alignment_scores, alpha) + return -np.sum(labels - logit_scores) + + new_alpha = minimize(alpha_target, + updated_parameters['alpha'], + jac=alpha_fprime)['x'][0] logit_scores = logit_partial_scores(alignment_scores, new_alpha) if verbose: diff --git a/src/logit_link.py b/src/logit_link.py index 44421be..abb29f0 100644 --- a/src/logit_link.py +++ b/src/logit_link.py @@ -50,6 +50,81 @@ def d2lda2(logit_scores, labels): return -np.sum(logit_scores*(1-logit_scores)) +def fit_alpha(alignment_scores, labels, alpha0, tol=1e-12, max_newton=8, maxiter=200): + """ + The intercept alpha that maximises the likelihood for fixed alignment + scores, starting from alpha0. + + This is the root of dlda, which is strictly decreasing in alpha, so it is + unique whenever both classes are present. Plain Newton steps are taken from + alpha0 while they stay small (|step| <= 1), which is the usual case when + alpha0 is the previous iteration's alpha. Otherwise the root is bracketed + and found by Newton steps inside the bracket, with bisection whenever a + Newton step would leave it or converge too slowly (Numerical Recipes' + rtsafe). The log-likelihood itself is never evaluated: logit_logL clips + probabilities, which makes line searches on it unreliable. + """ + alignment_scores = np.asarray(alignment_scores, dtype=float) + labels = np.asarray(labels, dtype=float) + positives = np.sum(labels) + if positives == 0 or positives == len(labels): + raise ValueError('Fitting alpha needs labels of both classes: with one class ' + 'the likelihood has no finite maximum') + + def derivatives(alpha): + logit_scores = logit_partial_scores(alignment_scores, alpha) + return float(dlda(logit_scores, labels)), float(-d2lda2(logit_scores, labels)) + + alpha = float(alpha0) + for _ in range(max_newton): + g, h = derivatives(alpha) + step = g / h if h > 0 else np.inf + if not np.isfinite(step) or abs(step) > 1.0: + break + alpha += step + if abs(step) <= tol * max(1.0, abs(alpha)): + return alpha + + # Bracket the root: dlda(lo) >= 0 >= dlda(hi). + lo, hi, width = alpha - 1.0, alpha + 1.0, 1.0 + for _ in range(maxiter): + if derivatives(lo)[0] >= 0: + break + lo -= width + width *= 2 + else: + raise RuntimeError('fit_alpha: could not bracket the root') + width = 1.0 + for _ in range(maxiter): + if derivatives(hi)[0] <= 0: + break + hi += width + width *= 2 + else: + raise RuntimeError('fit_alpha: could not bracket the root') + + alpha = min(max(alpha, lo), hi) + dx_old = dx = hi - lo + g, h = derivatives(alpha) + for _ in range(maxiter): + if g == 0.0: + return alpha + if h > 0 and lo <= alpha + g / h <= hi and abs(2 * g) <= abs(dx_old * h): + dx_old, dx = dx, g / h + alpha += dx + else: + dx_old, dx = dx, 0.5 * (hi - lo) + alpha = lo + dx + if abs(dx) <= tol * max(1.0, abs(alpha)): + return alpha + g, h = derivatives(alpha) + if g > 0: + lo = alpha + else: + hi = alpha + raise RuntimeError('fit_alpha: did not converge') + + def logit_subgradient(alignment_list, logit_scores, labels, alpha, alphabet): diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 0efe53d..25fe0d3 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -55,13 +55,9 @@ def _clip_gaps(params): def _assert_alpha_stationary(alpha, scores, labels): - """ - dlogL/dalpha vanishes at alpha, to scipy.minimize's default gtol (1e-5). - Where the likelihood is flat in alpha this is looser than it looks in - alpha itself, so alpha is also compared loosely with the exact root. - """ - assert abs(np.sum(labels - expit(alpha + scores))) < 1e-4 - assert alpha == pytest.approx(optimal_alpha(scores, labels), abs=1e-2) + """dlogL/dalpha vanishes at alpha: the default alpha solver finds the exact root.""" + assert abs(np.sum(labels - expit(alpha + scores))) < 1e-9 + assert alpha == pytest.approx(optimal_alpha(scores, labels), abs=1e-9) BACKENDS = ["biopython", "nwgrad"] @@ -549,3 +545,70 @@ def test_discrimalign_uses_default_stepfunction(): assert "final_loglik" in result assert "alpha" in result + + +# --- alpha_solver ------------------------------------------------------------- + +def _record_alpha_fits(monkeypatch): + """Wrap discrimalign's fit_alpha, recording (scores, labels, result) per call.""" + module = sys.modules["src.discrimalign"] + calls = [] + original = module.fit_alpha + + def recording(scores, labels, alpha0): + alpha = original(scores, labels, alpha0) + calls.append((np.array(scores, dtype=float), np.array(labels, dtype=float), alpha)) + return alpha + + monkeypatch.setattr(module, "fit_alpha", recording) + return calls + + +def test_default_alpha_solver_is_safeguarded_newton(): + import inspect + default = inspect.signature(discrimalign).parameters["alpha_solver"].default + assert default == "safeguarded_newton" + + +@pytest.mark.parametrize("backend", BACKENDS) +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", + [("local", "affine", "general"), ("global", "linear", "simple")]) +def test_alpha_is_the_exact_optimum_at_every_iteration(backend, mode, gap_mode, + substitution_mode, monkeypatch): + calls = _record_alpha_fits(monkeypatch) + _, A, B, y = _data(20) + res = _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, max_iter=4) + assert len(calls) == 4 + for scores, labels, alpha in calls: + assert alpha == pytest.approx(optimal_alpha(scores, labels), abs=1e-10) + assert res["alpha"] == calls[-1][2] + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_alpha_stays_exact_when_the_optimum_jumps_far(backend, monkeypatch): + """A large summed-gradient step moves every score far, so alpha* jumps between iterations.""" + calls = _record_alpha_fits(monkeypatch) + _, A, B, y = _data(21) + _run_on(backend, A, B, y, "local", "affine", "general", max_iter=4, + stepfunction=create_constant_step(5.0)) + jumps = [abs(b[2] - a[2]) for a, b in zip(calls, calls[1:])] + assert max(jumps) > 10 + for scores, labels, alpha in calls: + ref = optimal_alpha(scores, labels, bracket=(alpha - 100.0, alpha + 100.0)) + assert alpha == pytest.approx(ref, abs=1e-9) + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_bfgs_alpha_solver_reaches_the_same_fit(backend): + _, A, B, y = _data(22) + newton = _run_on(backend, A, B, y, "local", "affine", "general", max_iter=3) + bfgs = _run_on(backend, A, B, y, "local", "affine", "general", max_iter=3, + alpha_solver="bfgs") + assert bfgs["alpha"] == pytest.approx(newton["alpha"], abs=1e-4) + _assert_params_equal(bfgs, newton, _param_keys("affine", "general"), atol=1e-4) + + +def test_unknown_alpha_solver_is_rejected(): + _, A, B, y = _data(23) + with pytest.raises(AssertionError): + discrimalign(A, B, y, max_iter=0, alpha_solver="newton-cg") diff --git a/tests/test_logit_link.py b/tests/test_logit_link.py index a7eceea..2b02aed 100644 --- a/tests/test_logit_link.py +++ b/tests/test_logit_link.py @@ -3,9 +3,10 @@ import pytest from Bio.Align import PairwiseAligner -from src.logit_link import (d2lda2, dlda, logit_logL, logit_partial_scores, +from src.logit_link import (d2lda2, dlda, fit_alpha, logit_logL, logit_partial_scores, logit_subgradient) from src.optimization import EmptyLocalAlignment +from tests.helpers import optimal_alpha # --- logit_partial_scores --------------------------------------------------- @@ -136,6 +137,56 @@ def test_dlda_vanishes_when_mean_prediction_matches_mean_label(): assert dlda(np.array([0.25, 0.75]), np.array([0, 1])) == pytest.approx(0.0) +# --- fit_alpha ------------------------------------------------------------- + +def _alpha_data(seed, n=500, offset=0.0): + rng = np.random.default_rng(seed) + scores = rng.normal(-3.0, 2.0, n) + offset + labels = (rng.random(n) < 1.0 / (1.0 + np.exp(-(scores - offset + 1.5)))).astype(int) + return scores, labels + + +@pytest.mark.parametrize("seed", range(4)) +@pytest.mark.parametrize("start", [0.0, 1e-3, 0.5, -5.0, 20.0, -500.0, 500.0]) +def test_fit_alpha_finds_the_root_from_any_start(seed, start): + scores, labels = _alpha_data(seed) + ref = optimal_alpha(scores, labels) + alpha = fit_alpha(scores, labels, ref + start) + assert alpha == pytest.approx(ref, abs=1e-10) + assert abs(dlda(logit_partial_scores(scores, alpha), labels)) < 1e-8 + + +@pytest.mark.parametrize("offset", [300.0, -300.0, 1600.0]) +def test_fit_alpha_follows_a_large_jump_of_the_optimum(offset): + """Every score shifted far, as after an unscaled step on large data: alpha* moves by -offset.""" + scores, labels = _alpha_data(1, offset=offset) + ref = optimal_alpha(scores, labels, bracket=(-offset - 200.0, -offset + 200.0)) + assert fit_alpha(scores, labels, 0.0) == pytest.approx(ref, abs=1e-9) + + +def test_fit_alpha_does_not_depend_on_the_start(): + scores, labels = _alpha_data(2) + results = [fit_alpha(scores, labels, start) for start in (-50.0, -1.0, 0.0, 3.0, 50.0)] + assert max(results) - min(results) < 1e-10 + + +def test_fit_alpha_matches_mean_label_when_scores_are_equal(): + labels = np.array([1, 0, 0, 1, 1, 0, 1, 1]) + alpha = fit_alpha(np.zeros(len(labels)), labels, 0.0) + assert 1.0 / (1.0 + np.exp(-alpha)) == pytest.approx(labels.mean(), abs=1e-12) + + +def test_fit_alpha_returns_python_float_and_accepts_lists(): + alpha = fit_alpha([0.0, 1.0, -1.0, 2.0], [1, 0, 0, 1], 0) + assert isinstance(alpha, float) + + +@pytest.mark.parametrize("labels", [[1, 1, 1], [0, 0, 0]]) +def test_fit_alpha_needs_both_classes(labels): + with pytest.raises(ValueError, match="both classes"): + fit_alpha([0.0, 1.0, 2.0], labels, 0.0) + + # --- logit_subgradient on hand-built alignments ----------------------------- # # logit_subgradient only indexes aln[0] and aln[1], so a pair of gapped From b807a4b06a22a0186081f5a0acce31259953a0e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 10:02:22 +0200 Subject: [PATCH 42/65] Iteration loop: check and convert labels once, reuse probabilities in fit_alpha, fewer temporaries in the log-likelihood (bit-identical) --- src/discrimalign.py | 20 ++++++++++++++------ src/logit_link.py | 36 ++++++++++++++++++++++++++++-------- tests/test_discrimalign.py | 4 ++-- tests/test_logit_link.py | 22 ++++++++++++++++++++++ 4 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index ff21b54..5be2cb7 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -11,7 +11,8 @@ import os from .optimization import (create_powerstep, get_initial_estimate, get_initial_estimate_from_counts, get_first_alignment) -from .logit_link import fit_alpha, logit_partial_scores, logit_logL, logit_subgradient +from .logit_link import (_logit_logL_unchecked, fit_alpha, logit_partial_scores, logit_logL, + logit_subgradient) def _align_pair_chunk(pair_chunk, aligner): @@ -270,6 +271,12 @@ def add_noise(shape, niter): _clip_gap_scores(updated_parameters, gap_mode) _configure_aligner(aligner, updated_parameters, gap_mode, substitution_mode) + # The loop uses the labels as floats, checked once. + labels_float = np.asarray(labels, dtype=float) + if not np.isin(labels_float, [0, 1]).all(): + raise ValueError('Labels can only be 0 or 1') + one_minus_labels = 1 - labels_float + # Subgradient refinement loglik_trajectory = [] subgradient_l2_trajectory = [] @@ -280,10 +287,10 @@ def add_noise(shape, niter): print('Start of iteration', iternb) # Realign with the new parameters engine.set_params(updated_parameters) - alignment_scores = engine.scores() + alignment_scores = np.asarray(engine.scores(), dtype=float) logit_scores = logit_partial_scores(alignment_scores, updated_parameters['alpha']) - new_logL = logit_logL(logit_scores, labels) + new_logL = _logit_logL_unchecked(logit_scores, labels_float, one_minus_labels) loglik_trajectory.append(new_logL) if verbose: print("Current alpha:", updated_parameters['alpha']) @@ -300,7 +307,8 @@ def add_noise(shape, niter): # Optimize the logistic intercept (alpha) if alpha_solver == 'safeguarded_newton': - new_alpha = fit_alpha(alignment_scores, labels, updated_parameters['alpha']) + new_alpha = fit_alpha(alignment_scores, labels_float, updated_parameters['alpha'], + logit_scores0=logit_scores) else: def alpha_target(alpha): logit_scores = logit_partial_scores(alignment_scores, alpha) @@ -316,14 +324,14 @@ def alpha_fprime(alpha): logit_scores = logit_partial_scores(alignment_scores, new_alpha) if verbose: - new_logL = logit_logL(logit_scores, labels) + new_logL = _logit_logL_unchecked(logit_scores, labels_float, one_minus_labels) print("Updated alpha:", new_alpha) print('Updated logL:', new_logL) updated_parameters['alpha'] = new_alpha # Make a subgradient step - subgradient = engine.raw_subgradient(logit_scores, labels, new_alpha) + subgradient = engine.raw_subgradient(logit_scores, labels_float, new_alpha) if subgradient_scale != 1.0: subgradient['Gap opens'] *= subgradient_scale subgradient['Gap extends'] *= subgradient_scale diff --git a/src/logit_link.py b/src/logit_link.py index abb29f0..b38b4f5 100644 --- a/src/logit_link.py +++ b/src/logit_link.py @@ -26,11 +26,26 @@ def logit_logL(logit_scores, labels): """ logit_scores = np.asarray(logit_scores, dtype=float) labels = np.asarray(labels) - eps = np.finfo(float).eps - logit_scores = np.clip(logit_scores, eps, 1.0 - eps) if not np.isin(labels, [0, 1]).all(): raise ValueError('Labels can only be 0 or 1') - return float(np.sum(labels * np.log(logit_scores) + (1 - labels) * np.log1p(-logit_scores))) + return _logit_logL_unchecked(logit_scores, labels, 1 - labels) + + +def _logit_logL_unchecked(logit_scores, labels, one_minus_labels): + """ + logit_logL() for a float array of logit scores and labels already known to + be 0 or 1, given 1 - labels too: the same arithmetic in the same order, + with fewer temporary arrays. For loops that evaluate it on the same labels + many times. + """ + eps = np.finfo(float).eps + clipped = np.clip(logit_scores, eps, 1.0 - eps) + terms = np.log(clipped) + terms *= labels + other = np.log1p(np.negative(clipped, out=clipped)) + other *= one_minus_labels + terms += other + return float(np.sum(terms)) def dlda(logit_scores, labels): @@ -50,7 +65,8 @@ def d2lda2(logit_scores, labels): return -np.sum(logit_scores*(1-logit_scores)) -def fit_alpha(alignment_scores, labels, alpha0, tol=1e-12, max_newton=8, maxiter=200): +def fit_alpha(alignment_scores, labels, alpha0, tol=1e-12, max_newton=8, maxiter=200, + logit_scores0=None): """ The intercept alpha that maximises the likelihood for fixed alignment scores, starting from alpha0. @@ -63,6 +79,9 @@ def fit_alpha(alignment_scores, labels, alpha0, tol=1e-12, max_newton=8, maxiter Newton step would leave it or converge too slowly (Numerical Recipes' rtsafe). The log-likelihood itself is never evaluated: logit_logL clips probabilities, which makes line searches on it unreliable. + + logit_scores0, if given, must be logit_partial_scores(alignment_scores, + alpha0); a caller that already has them saves one pass over the data. """ alignment_scores = np.asarray(alignment_scores, dtype=float) labels = np.asarray(labels, dtype=float) @@ -71,13 +90,14 @@ def fit_alpha(alignment_scores, labels, alpha0, tol=1e-12, max_newton=8, maxiter raise ValueError('Fitting alpha needs labels of both classes: with one class ' 'the likelihood has no finite maximum') - def derivatives(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) + def derivatives(alpha, logit_scores=None): + if logit_scores is None: + logit_scores = logit_partial_scores(alignment_scores, alpha) return float(dlda(logit_scores, labels)), float(-d2lda2(logit_scores, labels)) alpha = float(alpha0) - for _ in range(max_newton): - g, h = derivatives(alpha) + for k in range(max_newton): + g, h = derivatives(alpha, logit_scores0 if k == 0 else None) step = g / h if h > 0 else np.inf if not np.isfinite(step) or abs(step) > 1.0: break diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 25fe0d3..43be310 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -555,8 +555,8 @@ def _record_alpha_fits(monkeypatch): calls = [] original = module.fit_alpha - def recording(scores, labels, alpha0): - alpha = original(scores, labels, alpha0) + def recording(scores, labels, alpha0, **kwargs): + alpha = original(scores, labels, alpha0, **kwargs) calls.append((np.array(scores, dtype=float), np.array(labels, dtype=float), alpha)) return alpha diff --git a/tests/test_logit_link.py b/tests/test_logit_link.py index 2b02aed..9ce5909 100644 --- a/tests/test_logit_link.py +++ b/tests/test_logit_link.py @@ -181,6 +181,28 @@ def test_fit_alpha_returns_python_float_and_accepts_lists(): assert isinstance(alpha, float) +@pytest.mark.parametrize("start", [0.0, 0.3, -40.0]) +def test_fit_alpha_given_starting_probabilities_is_bit_identical(start): + scores, labels = _alpha_data(3) + expected = fit_alpha(scores, labels, start) + given = fit_alpha(scores, labels, start, logit_scores0=logit_partial_scores(scores, start)) + assert given == expected + + +def test_logL_shared_arithmetic_is_bit_identical_to_the_formula(): + from src.logit_link import _logit_logL_unchecked + rng = np.random.default_rng(4) + p = rng.random(1000) + p[:5] = [0.0, 1.0, 1e-300, 1 - 1e-17, 0.5] + labels = rng.integers(0, 2, 1000) + eps = np.finfo(float).eps + clipped = np.clip(p, eps, 1.0 - eps) + formula = float(np.sum(labels * np.log(clipped) + (1 - labels) * np.log1p(-clipped))) + as_float = labels.astype(float) + assert logit_logL(p, labels) == formula + assert _logit_logL_unchecked(p, as_float, 1 - as_float) == formula + + @pytest.mark.parametrize("labels", [[1, 1, 1], [0, 0, 0]]) def test_fit_alpha_needs_both_classes(labels): with pytest.raises(ValueError, match="both classes"): From e07ded80e94f4ead3b189352d12d55d0f38d0fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 11:21:09 +0200 Subject: [PATCH 43/65] Use nwgrad.logistic.step for the per-iteration logistic work on the nwgrad backend when available --- src/discrimalign.py | 115 +++++++++++++++++++++++-------------- src/nwgrad_backend.py | 20 +++++++ tests/test_discrimalign.py | 36 +++++++++++- 3 files changed, 126 insertions(+), 45 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 5be2cb7..5212531 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -135,6 +135,59 @@ def raw_subgradient(self, logit_scores, labels, alpha): return logit_subgradient(self.alignments, logit_scores, labels, alpha, self.alphabet) +def _python_logistic_step(engine, updated_parameters, labels, labels_float, one_minus_labels, + alpha_solver, loglik_trajectory, verbose): + """ + Align, then one iteration's logistic work in numpy: the log-likelihood at + the current alpha (appended to loglik_trajectory), the alpha fit, and the + raw subgradient at the new alpha. Returns (new_alpha, subgradient). + """ + alignment_scores = np.asarray(engine.scores(), dtype=float) + logit_scores = logit_partial_scores(alignment_scores, + updated_parameters['alpha']) + new_logL = _logit_logL_unchecked(logit_scores, labels_float, one_minus_labels) + loglik_trajectory.append(new_logL) + if verbose: + print("Current alpha:", updated_parameters['alpha']) + print('Current logL:', new_logL) +## EL = 0 +## VL = 0 +## for ls in logit_scores: +## if 1e-30 < ls < 1-1e-30: +## EL += ls*np.log(ls) + (1-ls)*np.log(1-ls) +## VL += ls*(1-ls)*(np.log(ls)**2 + np.log(1-ls)**2) +## SDL = np.sqrt(VL) +## loglik_expectation.append(EL) +## loglik_sd.append(SDL) + + # Optimize the logistic intercept (alpha) + if alpha_solver == 'safeguarded_newton': + new_alpha = fit_alpha(alignment_scores, labels_float, updated_parameters['alpha'], + logit_scores0=logit_scores) + else: + def alpha_target(alpha): + logit_scores = logit_partial_scores(alignment_scores, alpha) + return -logit_logL(logit_scores, labels) + + def alpha_fprime(alpha): + logit_scores = logit_partial_scores(alignment_scores, alpha) + return -np.sum(labels - logit_scores) + + new_alpha = minimize(alpha_target, + updated_parameters['alpha'], + jac=alpha_fprime)['x'][0] + + logit_scores = logit_partial_scores(alignment_scores, new_alpha) + if verbose: + new_logL = _logit_logL_unchecked(logit_scores, labels_float, one_minus_labels) + print("Updated alpha:", new_alpha) + print('Updated logL:', new_logL) + + # The subgradient at the new alpha + subgradient = engine.raw_subgradient(logit_scores, labels_float, new_alpha) + return new_alpha, subgradient + + def discrimalign(seqlistA, seqlistB, labels, baseline_aligner=None, @@ -276,6 +329,10 @@ def add_noise(shape, niter): if not np.isin(labels_float, [0, 1]).all(): raise ValueError('Labels can only be 0 or 1') one_minus_labels = 1 - labels_float + # nwgrad releases with nwgrad.logistic do the logistic part of each + # iteration in C++ (the same alpha fit, same probabilities). + use_logistic_step = (alpha_solver == 'safeguarded_newton' + and getattr(engine, 'has_logistic_step', False)) # Subgradient refinement loglik_trajectory = [] @@ -287,51 +344,21 @@ def add_noise(shape, niter): print('Start of iteration', iternb) # Realign with the new parameters engine.set_params(updated_parameters) - alignment_scores = np.asarray(engine.scores(), dtype=float) - logit_scores = logit_partial_scores(alignment_scores, - updated_parameters['alpha']) - new_logL = _logit_logL_unchecked(logit_scores, labels_float, one_minus_labels) - loglik_trajectory.append(new_logL) - if verbose: - print("Current alpha:", updated_parameters['alpha']) - print('Current logL:', new_logL) -## EL = 0 -## VL = 0 -## for ls in logit_scores: -## if 1e-30 < ls < 1-1e-30: -## EL += ls*np.log(ls) + (1-ls)*np.log(1-ls) -## VL += ls*(1-ls)*(np.log(ls)**2 + np.log(1-ls)**2) -## SDL = np.sqrt(VL) -## loglik_expectation.append(EL) -## loglik_sd.append(SDL) - - # Optimize the logistic intercept (alpha) - if alpha_solver == 'safeguarded_newton': - new_alpha = fit_alpha(alignment_scores, labels_float, updated_parameters['alpha'], - logit_scores0=logit_scores) + if use_logistic_step: + # Alignment, log-likelihood, alpha fit and subgradient in nwgrad. + new_logL, new_alpha, subgradient = engine.logistic_step( + labels_float, updated_parameters['alpha']) + loglik_trajectory.append(new_logL) + if verbose: + print("Current alpha:", updated_parameters['alpha']) + print('Current logL:', new_logL) + print("Updated alpha:", new_alpha) + updated_parameters['alpha'] = new_alpha else: - def alpha_target(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return -logit_logL(logit_scores, labels) - - def alpha_fprime(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return -np.sum(labels - logit_scores) - - new_alpha = minimize(alpha_target, - updated_parameters['alpha'], - jac=alpha_fprime)['x'][0] - - logit_scores = logit_partial_scores(alignment_scores, new_alpha) - if verbose: - new_logL = _logit_logL_unchecked(logit_scores, labels_float, one_minus_labels) - print("Updated alpha:", new_alpha) - print('Updated logL:', new_logL) - - updated_parameters['alpha'] = new_alpha - - # Make a subgradient step - subgradient = engine.raw_subgradient(logit_scores, labels_float, new_alpha) + new_alpha, subgradient = _python_logistic_step( + engine, updated_parameters, labels, labels_float, one_minus_labels, + alpha_solver, loglik_trajectory, verbose) + updated_parameters['alpha'] = new_alpha if subgradient_scale != 1.0: subgradient['Gap opens'] *= subgradient_scale subgradient['Gap extends'] *= subgradient_scale diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index 0cf0ee3..c1c9146 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -7,6 +7,11 @@ import nwgrad from Bio.Align import substitution_matrices +try: # the logistic link in C++, in nwgrad releases that have it + import nwgrad.logistic as nwgrad_logistic +except ImportError: + nwgrad_logistic = None + from .nwgrad_params import GAP_FIELDS, gap_counts, grad_to_raw, to_nwgrad from .optimization import CountArrays @@ -91,6 +96,21 @@ def scores(self): self.batch.score_and_grad() return self.batch.scores() + @property + def has_logistic_step(self): + return nwgrad_logistic is not None + + def logistic_step(self, labels, alpha0): + """ + Align, then one iteration's logistic work in nwgrad (C++, parallel): + the log-likelihood at alpha0, the fitted alpha (as fit_alpha()), and + the raw subgradient at that alpha, in logit_subgradient's format. + labels: float64 array of 0s and 1s. + """ + self.batch.score_and_grad() + step = nwgrad_logistic.step(self.batch, labels, alpha0) + return step.loglik_at_alpha0, step.alpha, grad_to_raw(step.grad) + def raw_counts(self): """Per-pair counts in logit_subgradient's format, for the scores just computed.""" return [grad_to_raw(self.batch[i].grad) for i in range(len(self.batch))] diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 43be310..41f85f2 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -1,4 +1,5 @@ """Integration tests for src.discrimalign.discrimalign on small fixtures.""" +import importlib import sys import warnings from copy import deepcopy @@ -550,7 +551,10 @@ def test_discrimalign_uses_default_stepfunction(): # --- alpha_solver ------------------------------------------------------------- def _record_alpha_fits(monkeypatch): - """Wrap discrimalign's fit_alpha, recording (scores, labels, result) per call.""" + """ + Record (scores, labels, alpha) for every alpha fit: discrimalign's Python + fit_alpha, and nwgrad.logistic.step where the nwgrad backend uses it. + """ module = sys.modules["src.discrimalign"] calls = [] original = module.fit_alpha @@ -561,6 +565,19 @@ def recording(scores, labels, alpha0, **kwargs): return alpha monkeypatch.setattr(module, "fit_alpha", recording) + + backend = importlib.import_module("src.nwgrad_backend") + if backend.nwgrad_logistic is not None: + logistic = backend.nwgrad_logistic + + class RecordingLogistic: + @staticmethod + def step(batch, labels, alpha0): + st = logistic.step(batch, labels, alpha0) + calls.append((np.array(batch.scores()), np.array(labels, dtype=float), st.alpha)) + return st + + monkeypatch.setattr(backend, "nwgrad_logistic", RecordingLogistic) return calls @@ -612,3 +629,20 @@ def test_unknown_alpha_solver_is_rejected(): _, A, B, y = _data(23) with pytest.raises(AssertionError): discrimalign(A, B, y, max_iter=0, alpha_solver="newton-cg") + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_nwgrad_logistic_step_matches_the_python_path(mode, gap_mode, substitution_mode, monkeypatch): + """nwgrad.logistic.step and the numpy path give the same fit.""" + backend = importlib.import_module("src.nwgrad_backend") + if backend.nwgrad_logistic is None: + pytest.skip("nwgrad without nwgrad.logistic") + _, A, B, y = _data(24) + kwargs = dict(max_iter=5, stepfunction=create_constant_step(0.01)) + native = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, **kwargs) + monkeypatch.setattr(backend, "nwgrad_logistic", None) + python = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, **kwargs) + assert native["alpha"] == pytest.approx(python["alpha"], rel=1e-12, abs=1e-12) + np.testing.assert_allclose(native["loglik_trajectory"], python["loglik_trajectory"], rtol=1e-12) + _assert_params_equal(native, python, _param_keys(gap_mode, substitution_mode), + rtol=1e-12, atol=1e-12) From 271f9cbf97e92988c7140ca552c78210e3699bb2 Mon Sep 17 00:00:00 2001 From: mciach Date: Fri, 2 Oct 2026 11:25:15 +0200 Subject: [PATCH 44/65] Test on an actual use case --- tests/test_mirbench.py | 69 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_mirbench.py diff --git a/tests/test_mirbench.py b/tests/test_mirbench.py new file mode 100644 index 0000000..98a0152 --- /dev/null +++ b/tests/test_mirbench.py @@ -0,0 +1,69 @@ +""" +Short run on the miRBench datasets. Similar to a standard analysis. +""" +import pytest +from Bio.Seq import Seq +from Bio.SeqRecord import SeqRecord + +from miRBench.dataset import list_datasets, get_dataset_df +from src.discrimalign import discrimalign +from tests.helpers import (GAP_MODES, MODES, SUBSTITUTION_MODES, align_all, make_pairs, + mutate, random_seq) + +ALL_MODES = [(m, g, s) for m in MODES for g in GAP_MODES for s in SUBSTITUTION_MODES] +BACKENDS = ["biopython", "nwgrad"] +DATASET_IDs = [0, 2] + +def _get_data_and_params(dset_id): + assert dset_id in {0, 2} + train = get_dataset_df(list_datasets()[dset_id], split="train") + mirlist = train['noncodingRNA'] + mirlist = [str(Seq(seq)) for seq in mirlist] + genelist = train['gene'] + genelist = [str(Seq(seq).reverse_complement()) for seq in genelist] + if dset_id == 0: + stepfunction = create_constant_step(0.00005) + else: + stepfunction = create_constant_step(0.0000005) + return (mirlist, genelist, stepfunction) + +NITER = 20 + +@pytest.mark.parametrize("backend", BACKENDS) +@pytest.mark.parametrize("dset_id", DATASET_IDs) +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_mirbench_run(dset_id, mode, gap_mode, substitution_mode, backend): + """ + Check if discrimalign runs properly using data preprocessed with BioPython. + """ + mirlist, genelist, stepfunction = _get_data_and_params(dset_id) + res = discrimalign(mirlist, genelist, labels, + stepfunction=stepfunction, + aligner_mode=mode, + substitution_mode=substitution_mode, + gap_mode=gap_mode, + stochastic_factor=0.01, + verbose=True, max_iter=NITER, + backend=backend) + traj = res["loglik_trajectory"] + assert res["final_loglik"] > traj[0] + assert np.all(np.isfinite(traj)) + if substitution_mode == "simple": + assert res["match_score"] > res["mismatch_score"] + else: + M = np.asarray(res["substitution_matrix"]) + assert np.min(np.diag(M)) > np.mean(M[~np.eye(4, dtype=bool)]) + # In global mode, adding c to every substitution score and c/2 to every + # gap column shifts each score by c * (len A + len B) / 2. With equal + # lengths alpha absorbs that, so absolute signs are not identified. + # Local alignment breaks the symmetry. + if mode == "local": + if gap_mode == "affine": + assert res["open_gap_score"] < 0 + assert res["extend_gap_score"] < 0 + else: + assert res["gap_score"] < 0 + if substitution_mode == "simple": + assert res["match_score"] > 0 > res["mismatch_score"] + + From adfcea46b21d83003b1f991e7da752dc5467aff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 11:46:20 +0200 Subject: [PATCH 45/65] Skip Ciach's tests until he deciachs them. --- tests/test_mirbench.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_mirbench.py b/tests/test_mirbench.py index 98a0152..936c0fd 100644 --- a/tests/test_mirbench.py +++ b/tests/test_mirbench.py @@ -7,6 +7,7 @@ from miRBench.dataset import list_datasets, get_dataset_df from src.discrimalign import discrimalign +from src.optimization import create_powerstep, create_constant_step from tests.helpers import (GAP_MODES, MODES, SUBSTITUTION_MODES, align_all, make_pairs, mutate, random_seq) @@ -29,6 +30,7 @@ def _get_data_and_params(dset_id): NITER = 20 +@pytest.mark.skip() @pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("dset_id", DATASET_IDs) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) From b2348569aa9f7b3ef83024d95c78347d8fd01780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 12:12:18 +0200 Subject: [PATCH 46/65] Version bump nwgrad dep --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bba2418..99dd756 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "jupyterlab>=4.2.0", "matplotlib>=3.9.2", "mirbench", - "nwgrad>=0.5.0", + "nwgrad>=0.5.1", "numpy>=1.26.4", "pandas>=2.2.2", "scikit-learn>=1.4.2", From 8a961362ee2132f7d56d8c84235ea55ab69bfe3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 13:38:23 +0200 Subject: [PATCH 47/65] Add nwgrad_fill='rowwise' (nwgrad's row-wise DP fill, same fit, faster on short pairs); CI builds nwgrad from its perf-dp branch --- .github/workflows/tests.yml | 5 ++++- src/discrimalign.py | 14 ++++++++++++-- src/nwgrad_backend.py | 7 ++++++- tests/test_discrimalign.py | 29 +++++++++++++++++++++++++++++ tests/test_nwgrad_backend.py | 4 ++-- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 402761f..af1bca6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,5 +13,8 @@ jobs: with: python-version: "3.12" - run: uv sync + # This branch tests nwgrad_fill='rowwise', which needs nwgrad's unreleased + # SeqPairBatch.fill: build nwgrad from its perf-dp branch over the locked wheel. + - run: uv pip install "nwgrad @ git+https://github.com/michalsta/nwgrad@perf-dp" - run: uv pip show nwgrad - - run: uv run pytest + - run: uv run --no-sync pytest diff --git a/src/discrimalign.py b/src/discrimalign.py index 5212531..1b2b917 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -204,7 +204,8 @@ def discrimalign(seqlistA, seqlistB, return_alignments=True, verbose=False, backend='nwgrad', - alpha_solver='safeguarded_newton'): + alpha_solver='safeguarded_newton', + nwgrad_fill='striped'): """ backend selects where alignment scores and subgradients come from: 'nwgrad' (the default) or 'biopython' (PairwiseAligner). With 'nwgrad', the initial @@ -221,6 +222,11 @@ def discrimalign(seqlistA, seqlistB, in logit_link; 'bfgs' is the previous scipy.optimize.minimize (BFGS) fit, which can stop far from the optimum when alpha moves a long way between iterations, e.g. with subgradient_scale=1 on large data. + + nwgrad_fill selects nwgrad's vectorized DP fill: 'striped' (the default) or + 'rowwise', which gives the same scores and gradients and is 1.5-1.8x faster + on short pairs such as miRNA-target sites. 'rowwise' needs an nwgrad with + SeqPairBatch.fill. """ # TODO: Implement tol and additional stepfunctions. assert backend in {'biopython', 'nwgrad'} @@ -228,6 +234,10 @@ def discrimalign(seqlistA, seqlistB, assert aligner_mode in {'local', 'global'} assert gap_mode in {'affine', 'linear'} assert substitution_mode in {'general', 'symmetric', 'simple'} + if nwgrad_fill not in {'striped', 'rowwise'}: + raise ValueError(f"nwgrad_fill must be 'striped' or 'rowwise', got {nwgrad_fill!r}") + if nwgrad_fill != 'striped' and backend != 'nwgrad': + raise ValueError("nwgrad_fill applies to the nwgrad backend only") if stepfunction is None: stepfunction = create_powerstep(1e-4) for seqlist, name in ((seqlistA, 'seqlistA'), (seqlistB, 'seqlistB')): @@ -295,7 +305,7 @@ def add_noise(shape, niter): if backend == 'nwgrad': from .nwgrad_backend import NwgradEngine, baseline_parameters engine = NwgradEngine(seqlistA, seqlistB, aligner.mode, gap_mode, - substitution_mode, alphabet, num_threads) + substitution_mode, alphabet, num_threads, fill=nwgrad_fill) else: engine = _BiopythonEngine(seqlistA, seqlistB, aligner, gap_mode, substitution_mode, alphabet, num_threads) diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index c1c9146..adbc996 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -66,7 +66,7 @@ class NwgradEngine: """ def __init__(self, seqlistA, seqlistB, mode, gap_mode, substitution_mode, - alphabet, num_threads): + alphabet, num_threads, fill='striped'): self.seqlistA = list(seqlistA) self.seqlistB = list(seqlistB) self.mode = mode @@ -75,6 +75,11 @@ def __init__(self, seqlistA, seqlistB, mode, gap_mode, substitution_mode, self.alphabet = alphabet self.batch = nwgrad.SeqPairBatchDouble(n_threads=int(num_threads), traceback='pointers') + if fill != 'striped': + if not hasattr(self.batch, 'fill'): + raise ValueError(f"nwgrad_fill={fill!r} needs an nwgrad with " + "SeqPairBatch.fill (row-wise fill); this one has none") + self.batch.fill = fill self._built = False def set_params(self, params, substitution_mode=None): diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 41f85f2..78b5d81 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -646,3 +646,32 @@ def test_nwgrad_logistic_step_matches_the_python_path(mode, gap_mode, substituti np.testing.assert_allclose(native["loglik_trajectory"], python["loglik_trajectory"], rtol=1e-12) _assert_params_equal(native, python, _param_keys(gap_mode, substitution_mode), rtol=1e-12, atol=1e-12) + + +def _has_fill(): + import nwgrad + return hasattr(nwgrad.SeqPairBatchDouble(n_threads=1), "fill") + + +@pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) +def test_rowwise_fill_gives_the_same_fit(mode, gap_mode, substitution_mode): + """nwgrad_fill='rowwise' changes only the speed: the fit is bit-identical.""" + if not _has_fill(): + pytest.skip("nwgrad without SeqPairBatch.fill") + _, A, B, y = _data(25) + kwargs = dict(max_iter=5, stepfunction=create_constant_step(0.01)) + striped = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, **kwargs) + rowwise = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, + nwgrad_fill="rowwise", **kwargs) + assert rowwise["alpha"] == striped["alpha"] + assert rowwise["loglik_trajectory"] == striped["loglik_trajectory"] + _assert_params_equal(rowwise, striped, _param_keys(gap_mode, substitution_mode), + rtol=0, atol=0) + + +def test_nwgrad_fill_is_validated(): + _, A, B, y = _data(26) + with pytest.raises(ValueError, match="nwgrad_fill must be"): + discrimalign(A, B, y, max_iter=0, backend="nwgrad", nwgrad_fill="diagonal") + with pytest.raises(ValueError, match="nwgrad backend only"): + discrimalign(A, B, y, max_iter=0, backend="biopython", nwgrad_fill="rowwise") diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index 5213667..e2f6072 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -407,9 +407,9 @@ def test_automatic_thread_count(backend, explicit, monkeypatch): engine_init = nwgrad_backend.NwgradEngine.__init__ align_pairs = module._align_pairs - def spy_engine(self, *args): + def spy_engine(self, *args, **kwargs): seen.append(args[-1]) - engine_init(self, *args) + engine_init(self, *args, **kwargs) def spy_align(seqsA, seqsB, aligner, num_threads): seen.append(num_threads) From 1cb41177173fd4a6aeeef2c20b660a349be92892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 14:26:16 +0200 Subject: [PATCH 48/65] nwgrad_fill='interpair': nwgrad's inter-pair fill (several pairs per vector), same fit --- src/discrimalign.py | 14 ++++++++------ src/nwgrad_backend.py | 9 +++++---- tests/test_discrimalign.py | 27 ++++++++++++++++----------- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 1b2b917..59b2c8c 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -223,10 +223,11 @@ def discrimalign(seqlistA, seqlistB, which can stop far from the optimum when alpha moves a long way between iterations, e.g. with subgradient_scale=1 on large data. - nwgrad_fill selects nwgrad's vectorized DP fill: 'striped' (the default) or - 'rowwise', which gives the same scores and gradients and is 1.5-1.8x faster - on short pairs such as miRNA-target sites. 'rowwise' needs an nwgrad with - SeqPairBatch.fill. + nwgrad_fill selects nwgrad's vectorized DP fill: 'striped' (the default), + 'rowwise' or 'interpair'. All three give the same scores, gradients and fit, + bit for bit. On short pairs such as miRNA-target sites, 'rowwise' is about 2x + faster than 'striped', and 'interpair' (several pairs per vector) faster + still. The latter two need an nwgrad with SeqPairBatch.fill. """ # TODO: Implement tol and additional stepfunctions. assert backend in {'biopython', 'nwgrad'} @@ -234,8 +235,9 @@ def discrimalign(seqlistA, seqlistB, assert aligner_mode in {'local', 'global'} assert gap_mode in {'affine', 'linear'} assert substitution_mode in {'general', 'symmetric', 'simple'} - if nwgrad_fill not in {'striped', 'rowwise'}: - raise ValueError(f"nwgrad_fill must be 'striped' or 'rowwise', got {nwgrad_fill!r}") + if nwgrad_fill not in {'striped', 'rowwise', 'interpair'}: + raise ValueError("nwgrad_fill must be 'striped', 'rowwise' or 'interpair', " + f"got {nwgrad_fill!r}") if nwgrad_fill != 'striped' and backend != 'nwgrad': raise ValueError("nwgrad_fill applies to the nwgrad backend only") if stepfunction is None: diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index adbc996..d1fa5d9 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -76,10 +76,11 @@ def __init__(self, seqlistA, seqlistB, mode, gap_mode, substitution_mode, self.batch = nwgrad.SeqPairBatchDouble(n_threads=int(num_threads), traceback='pointers') if fill != 'striped': - if not hasattr(self.batch, 'fill'): - raise ValueError(f"nwgrad_fill={fill!r} needs an nwgrad with " - "SeqPairBatch.fill (row-wise fill); this one has none") - self.batch.fill = fill + try: + self.batch.fill = fill + except (AttributeError, ValueError) as error: + raise ValueError(f"nwgrad_fill={fill!r} is not supported by this nwgrad " + "(it needs SeqPairBatch.fill with that option)") from error self._built = False def set_params(self, params, substitution_mode=None): diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 78b5d81..32654e9 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -648,24 +648,29 @@ def test_nwgrad_logistic_step_matches_the_python_path(mode, gap_mode, substituti rtol=1e-12, atol=1e-12) -def _has_fill(): +def _has_fill(fill): import nwgrad - return hasattr(nwgrad.SeqPairBatchDouble(n_threads=1), "fill") + try: + nwgrad.SeqPairBatchDouble(n_threads=1).fill = fill + except (AttributeError, ValueError): + return False + return True +@pytest.mark.parametrize("fill", ["rowwise", "interpair"]) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_rowwise_fill_gives_the_same_fit(mode, gap_mode, substitution_mode): - """nwgrad_fill='rowwise' changes only the speed: the fit is bit-identical.""" - if not _has_fill(): - pytest.skip("nwgrad without SeqPairBatch.fill") +def test_fill_gives_the_same_fit(mode, gap_mode, substitution_mode, fill): + """nwgrad_fill changes only the speed: the fit is bit-identical.""" + if not _has_fill(fill): + pytest.skip(f"nwgrad without SeqPairBatch.fill = {fill!r}") _, A, B, y = _data(25) kwargs = dict(max_iter=5, stepfunction=create_constant_step(0.01)) striped = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, **kwargs) - rowwise = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, - nwgrad_fill="rowwise", **kwargs) - assert rowwise["alpha"] == striped["alpha"] - assert rowwise["loglik_trajectory"] == striped["loglik_trajectory"] - _assert_params_equal(rowwise, striped, _param_keys(gap_mode, substitution_mode), + other = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, + nwgrad_fill=fill, **kwargs) + assert other["alpha"] == striped["alpha"] + assert other["loglik_trajectory"] == striped["loglik_trajectory"] + _assert_params_equal(other, striped, _param_keys(gap_mode, substitution_mode), rtol=0, atol=0) From c5b931203077096cfb0e18f5a19b574d561f028c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Fri, 2 Oct 2026 15:10:34 +0200 Subject: [PATCH 49/65] README: document nwgrad_fill --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index a7c24ba..80014f2 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,8 @@ uv run python -m src.infer --model model.pkl --input examples/mirna_pairs.csv -- `num_threads=0`, the default, chooses the thread count automatically: all logical cores with the nwgrad backend, and one thread with the Biopython backend, whose threads contend for Python's global interpreter lock and only slow it down. Any other value is used as given. For long sequences such as full-length proteins, the number of physical cores can be faster than all logical cores; pass it explicitly. +`nwgrad_fill` selects nwgrad's vectorized DP fill: `"striped"` (default), `"rowwise"` or `"interpair"`. All three give the same scores, gradients and fit, bit for bit; only the speed differs. On short pairs such as miRNA-target sites the default is the slowest: on all 2.5 million Manakov training pairs (local/affine/general, 300 iterations, 12 threads on an i5-12500), the fit took 1072 s with `"striped"`, 469 s with `"rowwise"` and 291 s with `"interpair"`, which aligns several pairs at once, one per vector lane. The latter two need an nwgrad with `SeqPairBatch.fill` (currently its `perf-dp` branch); on long sequences such as proteins, keep the default. + ### Intercept fit At every iteration the intercept α is refitted to the current alignment scores. `alpha_solver` selects how: From 76f7ee88a4a5eb94a0341c3d93a224458539b6aa Mon Sep 17 00:00:00 2001 From: Elissavet Zacharopoulou Date: Fri, 2 Oct 2026 16:39:00 +0000 Subject: [PATCH 50/65] Add reverse-complement option for inference --- README.md | 12 ++++++++++++ case_study_for_mirna/README.md | 2 ++ src/infer.py | 6 ++++++ src/inference.py | 22 ++++++++++++++++++++-- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2c97dbf..dbe9871 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,18 @@ The output CSV includes: By default, `--normalize auto` converts `U`/`T` to match the trained model alphabet. Use `--normalize none` only if you want to disable this behavior. +If your second sequence column contains target/gene sequences before reverse-complementing, pass `--reverse-complement-b` so the second sequence is reverse-complemented before normalization and scoring: + +```bash +uv run python -m src.infer \ + --model manakov \ + --input my_pairs.csv \ + --output my_predictions.csv \ + --seq-a-column noncodingRNA \ + --seq-b-column gene \ + --reverse-complement-b +``` + ### 4. Use Your Own CSV Columns If your input columns have different names, pass them explicitly: diff --git a/case_study_for_mirna/README.md b/case_study_for_mirna/README.md index 84bd996..646c75a 100644 --- a/case_study_for_mirna/README.md +++ b/case_study_for_mirna/README.md @@ -179,3 +179,5 @@ uv run python -m src.infer --model hejret --input examples/mirna_pairs.csv --out ``` The output CSV includes the original sequences, normalized sequences, alignment score, logistic probability, aligned sequences, alignment markers, and per-position operations. The default `--normalize auto` converts `U`/`T` as needed to match the trained model alphabet. + +If your input uses manuscript-style columns where `gene` is the target sequence before reverse-complementing, pass `--seq-a-column noncodingRNA --seq-b-column gene --reverse-complement-b` so inference matches the case-study preprocessing. diff --git a/src/infer.py b/src/infer.py index afe9e89..72f4cc7 100644 --- a/src/infer.py +++ b/src/infer.py @@ -32,6 +32,11 @@ def main(argv=None): choices=["auto", "none"], help="Normalize U/T to match the trained model alphabet.", ) + parser.add_argument( + "--reverse-complement-b", + action="store_true", + help="Reverse-complement the second sequence column before scoring.", + ) args = parser.parse_args(argv) model_path = _resolve_model_path(args.model) @@ -43,6 +48,7 @@ def main(argv=None): sequence_a_column=args.seq_a_column, sequence_b_column=args.seq_b_column, normalize=args.normalize, + reverse_complement_b=args.reverse_complement_b, ) print(f"Loaded model from {model_path}") print(f"Wrote {len(rows)} prediction rows to {args.output}") diff --git a/src/inference.py b/src/inference.py index f14532c..97b7a06 100644 --- a/src/inference.py +++ b/src/inference.py @@ -4,6 +4,8 @@ import pickle from pathlib import Path +from Bio.Seq import Seq + from .logit_link import logit_partial_scores from .optimization import get_first_alignment @@ -42,6 +44,10 @@ def _normalize_sequence(sequence, alphabet, normalize): return sequence +def _reverse_complement(sequence): + return str(Seq(sequence).reverse_complement()) + + def _alignment_rows(alignment): target = str(alignment[0]) query = str(alignment[1]) @@ -74,7 +80,14 @@ def summarize_alignment(alignment): } -def predict_pairs(seqlistA, seqlistB, model, return_alignments=True, normalize="auto"): +def predict_pairs( + seqlistA, + seqlistB, + model, + return_alignments=True, + normalize="auto", + reverse_complement_b=False, +): """Score sequence pairs with a fitted DiscrimAlign model. Parameters @@ -87,6 +100,8 @@ def predict_pairs(seqlistA, seqlistB, model, return_alignments=True, normalize=" Include text and per-position alignment summaries in each row. normalize : {"auto", "none"}, default="auto" Convert U/T automatically when the fitted model alphabet requires it. + reverse_complement_b : bool, default=False + Reverse-complement each second sequence before normalization and scoring. """ seqlistA = list(seqlistA) seqlistB = list(seqlistB) @@ -97,8 +112,9 @@ def predict_pairs(seqlistA, seqlistB, model, return_alignments=True, normalize=" alphabet = _model_alphabet(aligner) rows = [] for index, (seqA, seqB) in enumerate(zip(seqlistA, seqlistB)): + transformed_seqB = _reverse_complement(seqB) if reverse_complement_b else seqB normalized_seqA = _normalize_sequence(seqA, alphabet, normalize) - normalized_seqB = _normalize_sequence(seqB, alphabet, normalize) + normalized_seqB = _normalize_sequence(transformed_seqB, alphabet, normalize) alignment = get_first_alignment(normalized_seqA, normalized_seqB, aligner) probability = float(logit_partial_scores([alignment.score], alpha)[0]) row = { @@ -140,6 +156,7 @@ def predict_csv( sequence_a_column="sequence_a", sequence_b_column="sequence_b", normalize="auto", + reverse_complement_b=False, ): """Run inference from a CSV file and write prediction rows to another CSV.""" input_csv = Path(input_csv) @@ -163,6 +180,7 @@ def predict_csv( [row[sequence_b_column] for row in input_rows], model, normalize=normalize, + reverse_complement_b=reverse_complement_b, ) output_rows = [] for input_row, prediction in zip(input_rows, predictions): From 29dad433834cf652829cbc679e0b922abb39cd52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Sat, 3 Oct 2026 10:20:29 +0200 Subject: [PATCH 51/65] CI and README: take nwgrad from main (perf-dp is merged) --- .github/workflows/tests.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index af1bca6..925deae 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,7 +14,7 @@ jobs: python-version: "3.12" - run: uv sync # This branch tests nwgrad_fill='rowwise', which needs nwgrad's unreleased - # SeqPairBatch.fill: build nwgrad from its perf-dp branch over the locked wheel. - - run: uv pip install "nwgrad @ git+https://github.com/michalsta/nwgrad@perf-dp" + # SeqPairBatch.fill: build nwgrad from its main branch over the locked wheel. + - run: uv pip install "nwgrad @ git+https://github.com/michalsta/nwgrad@main" - run: uv pip show nwgrad - run: uv run --no-sync pytest diff --git a/README.md b/README.md index 80014f2..56f3a42 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ uv run python -m src.infer --model model.pkl --input examples/mirna_pairs.csv -- `num_threads=0`, the default, chooses the thread count automatically: all logical cores with the nwgrad backend, and one thread with the Biopython backend, whose threads contend for Python's global interpreter lock and only slow it down. Any other value is used as given. For long sequences such as full-length proteins, the number of physical cores can be faster than all logical cores; pass it explicitly. -`nwgrad_fill` selects nwgrad's vectorized DP fill: `"striped"` (default), `"rowwise"` or `"interpair"`. All three give the same scores, gradients and fit, bit for bit; only the speed differs. On short pairs such as miRNA-target sites the default is the slowest: on all 2.5 million Manakov training pairs (local/affine/general, 300 iterations, 12 threads on an i5-12500), the fit took 1072 s with `"striped"`, 469 s with `"rowwise"` and 291 s with `"interpair"`, which aligns several pairs at once, one per vector lane. The latter two need an nwgrad with `SeqPairBatch.fill` (currently its `perf-dp` branch); on long sequences such as proteins, keep the default. +`nwgrad_fill` selects nwgrad's vectorized DP fill: `"striped"` (default), `"rowwise"` or `"interpair"`. All three give the same scores, gradients and fit, bit for bit; only the speed differs. On short pairs such as miRNA-target sites the default is the slowest: on all 2.5 million Manakov training pairs (local/affine/general, 300 iterations, 12 threads on an i5-12500), the fit took 1072 s with `"striped"`, 469 s with `"rowwise"` and 291 s with `"interpair"`, which aligns several pairs at once, one per vector lane. The latter two need an nwgrad with `SeqPairBatch.fill` (currently nwgrad `main`, unreleased); on long sequences such as proteins, keep the default. ### Intercept fit From 8854f135795466cdbb77c7795f555b7c97035e86 Mon Sep 17 00:00:00 2001 From: mciach Date: Sat, 3 Oct 2026 12:55:17 +0200 Subject: [PATCH 52/65] deciached --- tests/test_mirbench.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_mirbench.py b/tests/test_mirbench.py index 936c0fd..39a4ecb 100644 --- a/tests/test_mirbench.py +++ b/tests/test_mirbench.py @@ -22,15 +22,15 @@ def _get_data_and_params(dset_id): mirlist = [str(Seq(seq)) for seq in mirlist] genelist = train['gene'] genelist = [str(Seq(seq).reverse_complement()) for seq in genelist] + label_list = list(train['label']) if dset_id == 0: stepfunction = create_constant_step(0.00005) else: stepfunction = create_constant_step(0.0000005) - return (mirlist, genelist, stepfunction) + return (mirlist, genelist, label_list, stepfunction) NITER = 20 -@pytest.mark.skip() @pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("dset_id", DATASET_IDs) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) @@ -38,7 +38,7 @@ def test_mirbench_run(dset_id, mode, gap_mode, substitution_mode, backend): """ Check if discrimalign runs properly using data preprocessed with BioPython. """ - mirlist, genelist, stepfunction = _get_data_and_params(dset_id) + mirlist, genelist, labels, stepfunction = _get_data_and_params(dset_id) res = discrimalign(mirlist, genelist, labels, stepfunction=stepfunction, aligner_mode=mode, From c9b1404282b3fbc8113709684838548e7b2959e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Sat, 3 Oct 2026 13:27:20 +0200 Subject: [PATCH 53/65] Return nwgrad's own alignment paths (via SeqPair.coordinates) instead of realigning with Biopython --- src/discrimalign.py | 12 +++++++++--- src/nwgrad_backend.py | 38 +++++++++++++++++++++++++++++++++++- tests/test_discrimalign.py | 4 +++- tests/test_nwgrad_backend.py | 29 +++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 59b2c8c..967b8a2 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -211,7 +211,9 @@ def discrimalign(seqlistA, seqlistB, 'nwgrad' (the default) or 'biopython' (PairwiseAligner). With 'nwgrad', the initial estimate is fitted on nwgrad's alignments of the baseline too, and a baseline_aligner must have uniform gap scores and no wildcard. The - returned aligner and alignments always use Biopython. + returned aligner is a Biopython PairwiseAligner, and the returned alignments + are Biopython Alignment objects; with 'nwgrad' they are nwgrad's own paths + (needs nwgrad with SeqPair.coordinates(); older ones realign with Biopython). num_threads=0 (the default) picks the thread count automatically: all logical cores with 'nwgrad', 1 with 'biopython', whose threads contend @@ -457,8 +459,12 @@ def add_noise(shape, niter): engine.set_params(updated_parameters) alignment_scores = engine.scores() if backend == 'nwgrad': - alnlist = (_align_pairs(seqlistA, seqlistB, aligner, num_threads) - if return_alignments else None) + if not return_alignments: + alnlist = None + elif engine.has_alignments: + alnlist = engine.alignments() + else: # an nwgrad without SeqPair.coordinates(): realign with Biopython + alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) else: alnlist = engine.alignments logit_scores = logit_partial_scores(alignment_scores, diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index d1fa5d9..90a3d14 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -13,7 +13,7 @@ nwgrad_logistic = None from .nwgrad_params import GAP_FIELDS, gap_counts, grad_to_raw, to_nwgrad -from .optimization import CountArrays +from .optimization import CountArrays, EmptyLocalAlignment def baseline_parameters(aligner, gap_mode, alphabet): @@ -90,6 +90,7 @@ def set_params(self, params, substitution_mode=None): """ nw_params = to_nwgrad(params, self.gap_mode, substitution_mode or self.substitution_mode, self.alphabet) + self._nw_params = nw_params if self._built: self.batch.set_params(nw_params) else: @@ -98,6 +99,41 @@ def set_params(self, params, substitution_mode=None): grad_mode='hard') self._built = True + @property + def has_alignments(self): + return hasattr(nwgrad.SeqPairDouble, 'coordinates') + + def alignments(self, chunk_size=100_000): + """ + Every pair's alignment at the parameters last set: a Bio.Align.Alignment + with .score, or EmptyLocalAlignment for a local pair with no + positive-scoring alignment. These are nwgrad's own paths, the ones the + scores and subgradients come from, so on ties they agree with them, which + a Biopython realignment need not. Recovering a path needs pair-owned DP + tables, so the pairs are aligned chunk_size at a time to bound memory. + """ + from Bio.Align import Alignment + out = [] + for start in range(0, len(self.seqlistA), chunk_size): + seqs_a = self.seqlistA[start:start + chunk_size] + seqs_b = self.seqlistB[start:start + chunk_size] + batch = nwgrad.SeqPairBatchDouble(n_threads=self.batch.n_threads, + traceback='pointers') + batch.add_many(seqs_a, seqs_b, self._nw_params, gap_model=self.gap_mode, + mode=self.mode, grad_mode='none') + batch.alloc_dp() + batch.align_full() + for i, (seq_a, seq_b) in enumerate(zip(seqs_a, seqs_b)): + pair = batch[i] + coordinates = pair.coordinates() + if (coordinates[:, 0] == coordinates[:, -1]).all(): + out.append(EmptyLocalAlignment()) + continue + alignment = Alignment([seq_a, seq_b], coordinates) + alignment.score = pair.score + out.append(alignment) + return out + def scores(self): self.batch.score_and_grad() return self.batch.scores() diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 32654e9..18aed42 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -167,7 +167,9 @@ def test_final_outputs_are_consistent(mode, gap_mode, substitution_mode, backend res = _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, initial_parameters=p0) realigned = align_all(A, B, res["aligner"]) scores = np.array([a.score for a in res["alignments"]]) - np.testing.assert_array_equal(scores, [a.score for a in realigned]) + # nwgrad returns its own paths and scores, equal to Biopython's up to summation order. + tol = 0 if backend == "biopython" else 1e-12 + np.testing.assert_allclose(scores, [a.score for a in realigned], rtol=tol, atol=tol) np.testing.assert_allclose(res["alignment_logit_scores"], expit(res["alpha"] + scores), rtol=1e-14) assert res["final_loglik"] == pytest.approx(logit_logL(res["alignment_logit_scores"], y), rel=1e-14) # The returned aligner carries the returned parameters. diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index e2f6072..6afa89c 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -430,3 +430,32 @@ def spy_align(seqsA, seqsB, aligner, num_threads): one = discrimalign(A, B, y, num_threads=1, **kwargs) for key in _param_keys("affine", "general") | set(TRAJECTORY_KEYS): np.testing.assert_array_equal(np.asarray(auto[key]), np.asarray(one[key]), err_msg=key) + + +@pytest.mark.parametrize("mode", ["local", "global"]) +@pytest.mark.parametrize("gap_mode", ["affine", "linear"]) +@pytest.mark.parametrize("substitution_mode", ["general", "simple"]) +def test_nwgrad_alignments_are_the_paths_nwgrad_scored(mode, gap_mode, substitution_mode): + """The returned alignments are nwgrad's own paths: counting them gives exactly the + per-pair counts nwgrad's gradient used at the final parameters, ties included + (simple mode has many), and their scores are nwgrad's scores.""" + if not hasattr(nwgrad.SeqPairDouble, "coordinates"): + pytest.skip("nwgrad without SeqPair.coordinates()") + from src.logit_link import logit_subgradient + from src.nwgrad_backend import NwgradEngine + rng, A, B, y = _data(31, n=16, length=14) + res = discrimalign(A, B, y, aligner_mode=mode, gap_mode=gap_mode, + substitution_mode=substitution_mode, backend="nwgrad", max_iter=3, + stepfunction=create_constant_step(0.01), alphabet="ACGT") + engine = NwgradEngine(A, B, mode, gap_mode, substitution_mode, "ACGT", 2) + engine.set_params(res) + scores = engine.scores() + counts = engine.count_arrays() + for i, aln in enumerate(res["alignments"]): + assert aln.score == scores[i] + c = logit_subgradient([aln], [0.0], [1], res["alpha"], "ACGT") + np.testing.assert_array_equal(np.asarray(c["Substitutions"]), counts.substitutions[i]) + if gap_mode == "affine": + assert (c["Gap opens"], c["Gap extends"]) == (counts.gap_opens[i], counts.gap_extends[i]) + else: # the linear model reports gap columns only + assert c["Gap opens"] + c["Gap extends"] == counts.gap_opens[i] + counts.gap_extends[i] From ec228140c0910fdd92b36c9b60f811d121ba0dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Sat, 3 Oct 2026 13:54:13 +0200 Subject: [PATCH 54/65] Require nwgrad 0.5.2; drop the code paths for older nwgrad; CI back on the PyPI wheel --- .github/workflows/tests.yml | 5 +--- README.md | 4 +-- pyproject.toml | 2 +- src/discrimalign.py | 18 +++++-------- src/nwgrad_backend.py | 49 ++++-------------------------------- tests/test_discrimalign.py | 35 ++++++++------------------ tests/test_nwgrad_backend.py | 15 +++-------- 7 files changed, 29 insertions(+), 99 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 925deae..402761f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,8 +13,5 @@ jobs: with: python-version: "3.12" - run: uv sync - # This branch tests nwgrad_fill='rowwise', which needs nwgrad's unreleased - # SeqPairBatch.fill: build nwgrad from its main branch over the locked wheel. - - run: uv pip install "nwgrad @ git+https://github.com/michalsta/nwgrad@main" - run: uv pip show nwgrad - - run: uv run --no-sync pytest + - run: uv run pytest diff --git a/README.md b/README.md index 56f3a42..5d5a8ad 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ pyproject.toml Project environment managed by uv - Python `>=3.10` - `uv` for environment management -- `nwgrad` 0.5.0 or later, the default alignment backend. `uv sync` installs it as a binary wheel. Building it from source needs a C++20 compiler that provides ``, such as GCC; on macOS use Homebrew GCC (`CC=gcc-16 CXX=g++-16`), since Apple's clang does not provide it. +- `nwgrad` 0.5.2 or later, the default alignment backend. `uv sync` installs it as a binary wheel. Building it from source needs a C++20 compiler that provides ``, such as GCC; on macOS use Homebrew GCC (`CC=gcc-16 CXX=g++-16`), since Apple's clang does not provide it. - JupyterLab or VS Code notebook support for running `Simulation experiments.ipynb` The repository uses a single project environment managed by `uv`. This environment includes the scientific Python dependencies, JupyterLab, an IPython kernel for notebooks, and `miRBench` for the miRNA case-study dataset interface. @@ -222,7 +222,7 @@ uv run python -m src.infer --model model.pkl --input examples/mirna_pairs.csv -- `num_threads=0`, the default, chooses the thread count automatically: all logical cores with the nwgrad backend, and one thread with the Biopython backend, whose threads contend for Python's global interpreter lock and only slow it down. Any other value is used as given. For long sequences such as full-length proteins, the number of physical cores can be faster than all logical cores; pass it explicitly. -`nwgrad_fill` selects nwgrad's vectorized DP fill: `"striped"` (default), `"rowwise"` or `"interpair"`. All three give the same scores, gradients and fit, bit for bit; only the speed differs. On short pairs such as miRNA-target sites the default is the slowest: on all 2.5 million Manakov training pairs (local/affine/general, 300 iterations, 12 threads on an i5-12500), the fit took 1072 s with `"striped"`, 469 s with `"rowwise"` and 291 s with `"interpair"`, which aligns several pairs at once, one per vector lane. The latter two need an nwgrad with `SeqPairBatch.fill` (currently nwgrad `main`, unreleased); on long sequences such as proteins, keep the default. +`nwgrad_fill` selects nwgrad's vectorized DP fill: `"striped"` (default), `"rowwise"` or `"interpair"`. All three give the same scores, gradients and fit, bit for bit; only the speed differs. On short pairs such as miRNA-target sites the default is the slowest: on all 2.5 million Manakov training pairs (local/affine/general, 300 iterations, 12 threads on an i5-12500), the fit took 1072 s with `"striped"`, 469 s with `"rowwise"` and 291 s with `"interpair"`, which aligns several pairs at once, one per vector lane. On long sequences such as proteins, keep the default. ### Intercept fit diff --git a/pyproject.toml b/pyproject.toml index 99dd756..d14d820 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "jupyterlab>=4.2.0", "matplotlib>=3.9.2", "mirbench", - "nwgrad>=0.5.1", + "nwgrad>=0.5.2", "numpy>=1.26.4", "pandas>=2.2.2", "scikit-learn>=1.4.2", diff --git a/src/discrimalign.py b/src/discrimalign.py index 967b8a2..d6be819 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -212,8 +212,7 @@ def discrimalign(seqlistA, seqlistB, estimate is fitted on nwgrad's alignments of the baseline too, and a baseline_aligner must have uniform gap scores and no wildcard. The returned aligner is a Biopython PairwiseAligner, and the returned alignments - are Biopython Alignment objects; with 'nwgrad' they are nwgrad's own paths - (needs nwgrad with SeqPair.coordinates(); older ones realign with Biopython). + are Biopython Alignment objects; with 'nwgrad' they are nwgrad's own paths. num_threads=0 (the default) picks the thread count automatically: all logical cores with 'nwgrad', 1 with 'biopython', whose threads contend @@ -229,7 +228,7 @@ def discrimalign(seqlistA, seqlistB, 'rowwise' or 'interpair'. All three give the same scores, gradients and fit, bit for bit. On short pairs such as miRNA-target sites, 'rowwise' is about 2x faster than 'striped', and 'interpair' (several pairs per vector) faster - still. The latter two need an nwgrad with SeqPairBatch.fill. + still. """ # TODO: Implement tol and additional stepfunctions. assert backend in {'biopython', 'nwgrad'} @@ -343,10 +342,10 @@ def add_noise(shape, niter): if not np.isin(labels_float, [0, 1]).all(): raise ValueError('Labels can only be 0 or 1') one_minus_labels = 1 - labels_float - # nwgrad releases with nwgrad.logistic do the logistic part of each - # iteration in C++ (the same alpha fit, same probabilities). + # The nwgrad engine does the logistic part of each iteration in C++ (the + # same alpha fit, same probabilities); the Biopython engine has no such step. use_logistic_step = (alpha_solver == 'safeguarded_newton' - and getattr(engine, 'has_logistic_step', False)) + and hasattr(engine, 'logistic_step')) # Subgradient refinement loglik_trajectory = [] @@ -459,12 +458,7 @@ def add_noise(shape, niter): engine.set_params(updated_parameters) alignment_scores = engine.scores() if backend == 'nwgrad': - if not return_alignments: - alnlist = None - elif engine.has_alignments: - alnlist = engine.alignments() - else: # an nwgrad without SeqPair.coordinates(): realign with Biopython - alnlist = _align_pairs(seqlistA, seqlistB, aligner, num_threads) + alnlist = engine.alignments() if return_alignments else None else: alnlist = engine.alignments logit_scores = logit_partial_scores(alignment_scores, diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index 90a3d14..91baa0b 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -5,14 +5,10 @@ """ import numpy as np import nwgrad +import nwgrad.logistic as nwgrad_logistic from Bio.Align import substitution_matrices -try: # the logistic link in C++, in nwgrad releases that have it - import nwgrad.logistic as nwgrad_logistic -except ImportError: - nwgrad_logistic = None - -from .nwgrad_params import GAP_FIELDS, gap_counts, grad_to_raw, to_nwgrad +from .nwgrad_params import gap_counts, grad_to_raw, to_nwgrad from .optimization import CountArrays, EmptyLocalAlignment @@ -75,12 +71,7 @@ def __init__(self, seqlistA, seqlistB, mode, gap_mode, substitution_mode, self.alphabet = alphabet self.batch = nwgrad.SeqPairBatchDouble(n_threads=int(num_threads), traceback='pointers') - if fill != 'striped': - try: - self.batch.fill = fill - except (AttributeError, ValueError) as error: - raise ValueError(f"nwgrad_fill={fill!r} is not supported by this nwgrad " - "(it needs SeqPairBatch.fill with that option)") from error + self.batch.fill = fill self._built = False def set_params(self, params, substitution_mode=None): @@ -99,10 +90,6 @@ def set_params(self, params, substitution_mode=None): grad_mode='hard') self._built = True - @property - def has_alignments(self): - return hasattr(nwgrad.SeqPairDouble, 'coordinates') - def alignments(self, chunk_size=100_000): """ Every pair's alignment at the parameters last set: a Bio.Align.Alignment @@ -138,10 +125,6 @@ def scores(self): self.batch.score_and_grad() return self.batch.scores() - @property - def has_logistic_step(self): - return nwgrad_logistic is not None - def logistic_step(self, labels, alpha0): """ Align, then one iteration's logistic work in nwgrad (C++, parallel): @@ -159,35 +142,13 @@ def raw_counts(self): def count_arrays(self): """ - The same per-pair counts as raw_counts(), as CountArrays: from one - SeqPairBatch.grads() call where nwgrad has it, else pair by pair. + The same per-pair counts as raw_counts(), as CountArrays, from one + SeqPairBatch.grads() call. """ - if hasattr(self.batch, 'grads') and len(self.batch): - return self._count_arrays_bulk() - return self._count_arrays_per_pair() - - def _count_arrays_bulk(self): matrices, gaps = self.batch.grads() gap_opens, gap_extends = gap_counts(*gaps.T) return CountArrays(matrices, gap_opens, gap_extends, self.batch.alphabet) - def _count_arrays_per_pair(self): - n = len(self.batch) - substitutions = None - gap_opens = np.empty(n) - gap_extends = np.empty(n) - for i in range(n): - grad = self.batch[i].grad.to_dict() - if substitutions is None: - alphabet = grad['alphabet'] - substitutions = np.empty((n, len(alphabet), len(alphabet))) - substitutions[i] = grad['matrix'] - gap_opens[i], gap_extends[i] = gap_counts(*(grad[field] for field in GAP_FIELDS)) - if substitutions is None: - alphabet = self.alphabet - substitutions = np.empty((0, len(alphabet), len(alphabet))) - return CountArrays(substitutions, gap_opens, gap_extends, alphabet) - def raw_subgradient(self, logit_scores, labels, alpha): """ sum_i (label_i - logit_score_i) * counts_i, in logit_subgradient's diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 18aed42..ebcbafb 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -569,17 +569,16 @@ def recording(scores, labels, alpha0, **kwargs): monkeypatch.setattr(module, "fit_alpha", recording) backend = importlib.import_module("src.nwgrad_backend") - if backend.nwgrad_logistic is not None: - logistic = backend.nwgrad_logistic + logistic = backend.nwgrad_logistic - class RecordingLogistic: - @staticmethod - def step(batch, labels, alpha0): - st = logistic.step(batch, labels, alpha0) - calls.append((np.array(batch.scores()), np.array(labels, dtype=float), st.alpha)) - return st + class RecordingLogistic: + @staticmethod + def step(batch, labels, alpha0): + st = logistic.step(batch, labels, alpha0) + calls.append((np.array(batch.scores()), np.array(labels, dtype=float), st.alpha)) + return st - monkeypatch.setattr(backend, "nwgrad_logistic", RecordingLogistic) + monkeypatch.setattr(backend, "nwgrad_logistic", RecordingLogistic) return calls @@ -635,14 +634,13 @@ def test_unknown_alpha_solver_is_rejected(): @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) def test_nwgrad_logistic_step_matches_the_python_path(mode, gap_mode, substitution_mode, monkeypatch): - """nwgrad.logistic.step and the numpy path give the same fit.""" + """nwgrad.logistic.step and the numpy path (the Biopython backend's) give the same fit.""" backend = importlib.import_module("src.nwgrad_backend") - if backend.nwgrad_logistic is None: - pytest.skip("nwgrad without nwgrad.logistic") _, A, B, y = _data(24) kwargs = dict(max_iter=5, stepfunction=create_constant_step(0.01)) native = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, **kwargs) - monkeypatch.setattr(backend, "nwgrad_logistic", None) + # Without logistic_step, discrimalign() runs the numpy path on the nwgrad engine. + monkeypatch.delattr(backend.NwgradEngine, "logistic_step") python = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, **kwargs) assert native["alpha"] == pytest.approx(python["alpha"], rel=1e-12, abs=1e-12) np.testing.assert_allclose(native["loglik_trajectory"], python["loglik_trajectory"], rtol=1e-12) @@ -650,21 +648,10 @@ def test_nwgrad_logistic_step_matches_the_python_path(mode, gap_mode, substituti rtol=1e-12, atol=1e-12) -def _has_fill(fill): - import nwgrad - try: - nwgrad.SeqPairBatchDouble(n_threads=1).fill = fill - except (AttributeError, ValueError): - return False - return True - - @pytest.mark.parametrize("fill", ["rowwise", "interpair"]) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) def test_fill_gives_the_same_fit(mode, gap_mode, substitution_mode, fill): """nwgrad_fill changes only the speed: the fit is bit-identical.""" - if not _has_fill(fill): - pytest.skip(f"nwgrad without SeqPairBatch.fill = {fill!r}") _, A, B, y = _data(25) kwargs = dict(max_iter=5, stepfunction=create_constant_step(0.01)) striped = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, **kwargs) diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index 6afa89c..ffff7f5 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -112,16 +112,9 @@ def test_nwgrad_initial_estimate_is_fitted_on_nwgrad_counts(mode, gap_mode, subs assert res["alpha"] == pytest.approx(expected["alpha"], rel=1e-12) -NWGRAD_HAS_GRADS = hasattr(nwgrad.SeqPairBatchDouble, "grads") - - -@pytest.mark.parametrize("path", [ - "count_arrays", "_count_arrays_per_pair", - pytest.param("_count_arrays_bulk", marks=pytest.mark.skipif( - not NWGRAD_HAS_GRADS, reason="nwgrad without SeqPairBatch.grads()"))]) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) -def test_count_arrays_equal_raw_counts(mode, gap_mode, substitution_mode, path): - """count_arrays(), by either path, gives exactly the per-pair counts of raw_counts().""" +def test_count_arrays_equal_raw_counts(mode, gap_mode, substitution_mode): + """count_arrays() gives exactly the per-pair counts of raw_counts().""" from src.nwgrad_backend import NwgradEngine from src.optimization import _count_arrays_from_raw rng, A, B, _ = _data(5, n=20) @@ -132,7 +125,7 @@ def test_count_arrays_equal_raw_counts(mode, gap_mode, substitution_mode, path): (random_params(rng, gap_mode, substitution_mode), None)): engine.set_params(params, substitution_mode=params_mode) engine.scores() - counts = getattr(engine, path)() + counts = engine.count_arrays() expected = _count_arrays_from_raw(engine.raw_counts()) assert counts.alphabet == expected.alphabet for field in ("substitutions", "gap_opens", "gap_extends"): @@ -439,8 +432,6 @@ def test_nwgrad_alignments_are_the_paths_nwgrad_scored(mode, gap_mode, substitut """The returned alignments are nwgrad's own paths: counting them gives exactly the per-pair counts nwgrad's gradient used at the final parameters, ties included (simple mode has many), and their scores are nwgrad's scores.""" - if not hasattr(nwgrad.SeqPairDouble, "coordinates"): - pytest.skip("nwgrad without SeqPair.coordinates()") from src.logit_link import logit_subgradient from src.nwgrad_backend import NwgradEngine rng, A, B, y = _data(31, n=16, length=14) From 6c7f44a6a6035aa3ae60d9d2eccbb31896ea2299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Sat, 3 Oct 2026 14:03:59 +0200 Subject: [PATCH 55/65] test_mirbench: import numpy, 2 iterations, mark slow and run only with DISCRIMALIGN_RUN_MIRBENCH=1 --- pyproject.toml | 2 +- tests/test_mirbench.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d14d820..a2dd734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,5 +31,5 @@ dev = [ testpaths = ["tests"] pythonpath = ["."] markers = [ - "slow: end-to-end runs on larger simulated data (deselect with -m 'not slow')", + "slow: end-to-end runs on larger data (deselect with -m 'not slow'); the miRBench runs also need DISCRIMALIGN_RUN_MIRBENCH=1", ] diff --git a/tests/test_mirbench.py b/tests/test_mirbench.py index 39a4ecb..dcb6454 100644 --- a/tests/test_mirbench.py +++ b/tests/test_mirbench.py @@ -1,6 +1,12 @@ """ Short run on the miRBench datasets. Similar to a standard analysis. + +Slow and optional: it downloads miRBench datasets and fits every mode on the full +training sets (millions of pairs), so it runs only with DISCRIMALIGN_RUN_MIRBENCH=1. """ +import os + +import numpy as np import pytest from Bio.Seq import Seq from Bio.SeqRecord import SeqRecord @@ -29,7 +35,13 @@ def _get_data_and_params(dset_id): stepfunction = create_constant_step(0.0000005) return (mirlist, genelist, label_list, stepfunction) -NITER = 20 +NITER = 2 + +pytestmark = [ + pytest.mark.slow, + pytest.mark.skipif(os.environ.get("DISCRIMALIGN_RUN_MIRBENCH") != "1", + reason="miRBench end-to-end runs: set DISCRIMALIGN_RUN_MIRBENCH=1"), +] @pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("dset_id", DATASET_IDs) From fa0da039d671bc7a61ddb80c0d573346c93d534d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Sat, 3 Oct 2026 14:07:10 +0200 Subject: [PATCH 56/65] test_mirbench: back to 20 iterations --- tests/test_mirbench.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_mirbench.py b/tests/test_mirbench.py index dcb6454..55078e8 100644 --- a/tests/test_mirbench.py +++ b/tests/test_mirbench.py @@ -35,7 +35,7 @@ def _get_data_and_params(dset_id): stepfunction = create_constant_step(0.0000005) return (mirlist, genelist, label_list, stepfunction) -NITER = 2 +NITER = 20 pytestmark = [ pytest.mark.slow, From 0fc439a76f2de930efbc9963f607ed2592a76e26 Mon Sep 17 00:00:00 2001 From: mciach Date: Mon, 5 Oct 2026 13:11:25 +0200 Subject: [PATCH 57/65] Idk --- src/discrimalign.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 5212531..bc44da7 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -444,11 +444,12 @@ def add_noise(shape, niter): # Realign with the new parameters engine.set_params(updated_parameters) alignment_scores = engine.scores() - if backend == 'nwgrad': - alnlist = (_align_pairs(seqlistA, seqlistB, aligner, num_threads) - if return_alignments else None) - else: - alnlist = engine.alignments + if return_alignments: + if backend == 'nwgrad': + alnlist = (_align_pairs(seqlistA, seqlistB, aligner, num_threads) + if return_alignments else None) + else: + alnlist = engine.alignments logit_scores = logit_partial_scores(alignment_scores, updated_parameters['alpha']) new_logL = logit_logL(logit_scores, labels) From 849d924e9299b9c0709e8d461593787587833018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Mon, 5 Oct 2026 17:58:20 +0200 Subject: [PATCH 58/65] Restore nwgrad's own final alignment paths, reverted by the da11c3f merge --- src/discrimalign.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index b65d7f4..d6be819 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -457,12 +457,10 @@ def add_noise(shape, niter): # Realign with the new parameters engine.set_params(updated_parameters) alignment_scores = engine.scores() - if return_alignments: - if backend == 'nwgrad': - alnlist = (_align_pairs(seqlistA, seqlistB, aligner, num_threads) - if return_alignments else None) - else: - alnlist = engine.alignments + if backend == 'nwgrad': + alnlist = engine.alignments() if return_alignments else None + else: + alnlist = engine.alignments logit_scores = logit_partial_scores(alignment_scores, updated_parameters['alpha']) new_logL = logit_logL(logit_scores, labels) From 6e8ed1912f5e6a1c8336299059a3dafd00015b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Mon, 5 Oct 2026 18:06:10 +0200 Subject: [PATCH 59/65] Trace the returned nwgrad alignments with the fill the fit used --- src/nwgrad_backend.py | 2 ++ tests/test_nwgrad_backend.py | 11 +++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index 91baa0b..594d084 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -71,6 +71,7 @@ def __init__(self, seqlistA, seqlistB, mode, gap_mode, substitution_mode, self.alphabet = alphabet self.batch = nwgrad.SeqPairBatchDouble(n_threads=int(num_threads), traceback='pointers') + self.fill = fill self.batch.fill = fill self._built = False @@ -106,6 +107,7 @@ def alignments(self, chunk_size=100_000): seqs_b = self.seqlistB[start:start + chunk_size] batch = nwgrad.SeqPairBatchDouble(n_threads=self.batch.n_threads, traceback='pointers') + batch.fill = self.fill batch.add_many(seqs_a, seqs_b, self._nw_params, gap_model=self.gap_mode, mode=self.mode, grad_mode='none') batch.alloc_dp() diff --git a/tests/test_nwgrad_backend.py b/tests/test_nwgrad_backend.py index ffff7f5..6bf7b12 100644 --- a/tests/test_nwgrad_backend.py +++ b/tests/test_nwgrad_backend.py @@ -428,17 +428,20 @@ def spy_align(seqsA, seqsB, aligner, num_threads): @pytest.mark.parametrize("mode", ["local", "global"]) @pytest.mark.parametrize("gap_mode", ["affine", "linear"]) @pytest.mark.parametrize("substitution_mode", ["general", "simple"]) -def test_nwgrad_alignments_are_the_paths_nwgrad_scored(mode, gap_mode, substitution_mode): +@pytest.mark.parametrize("fill", ["striped", "rowwise", "interpair"]) +def test_nwgrad_alignments_are_the_paths_nwgrad_scored(mode, gap_mode, substitution_mode, fill): """The returned alignments are nwgrad's own paths: counting them gives exactly the per-pair counts nwgrad's gradient used at the final parameters, ties included - (simple mode has many), and their scores are nwgrad's scores.""" + (simple mode has many), and their scores are nwgrad's scores. This holds for + every fill, so the paths must be traced with the fill the fit used.""" from src.logit_link import logit_subgradient from src.nwgrad_backend import NwgradEngine rng, A, B, y = _data(31, n=16, length=14) res = discrimalign(A, B, y, aligner_mode=mode, gap_mode=gap_mode, substitution_mode=substitution_mode, backend="nwgrad", max_iter=3, - stepfunction=create_constant_step(0.01), alphabet="ACGT") - engine = NwgradEngine(A, B, mode, gap_mode, substitution_mode, "ACGT", 2) + stepfunction=create_constant_step(0.01), alphabet="ACGT", + nwgrad_fill=fill) + engine = NwgradEngine(A, B, mode, gap_mode, substitution_mode, "ACGT", 2, fill=fill) engine.set_params(res) scores = engine.scores() counts = engine.count_arrays() From c4abdf2892bdadef824b79e6243c2f2746bb346b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Mon, 5 Oct 2026 18:06:10 +0200 Subject: [PATCH 60/65] README: nwgrad's returned alignments are its own paths, not a Biopython realignment --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dafac9b..bbf3237 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ On all 2.5 million Manakov training pairs, 300 iterations took 18.4 minutes with - `"nwgrad"` (default): the [nwgrad](https://github.com/michalsta/nwgrad) C++ library aligns all pairs in parallel and returns each alignment's score and gradient in one pass. The pairs are encoded once and reused across iterations. - `"biopython"`: Biopython's `PairwiseAligner`, with the gradient counted in Python from the alignment strings. -Both backends fit the same model and return the same kinds of objects: the returned `aligner` and `alignments` are Biopython objects with either backend. With `"nwgrad"` and `return_alignments=True`, the final alignments are computed with Biopython once at the end; pass `return_alignments=False` to skip that pass. +Both backends fit the same model and return the same kinds of objects: the returned `aligner` and `alignments` are Biopython objects with either backend. With `"nwgrad"`, the alignments are nwgrad's own paths at the final parameters, wrapped as `Bio.Align.Alignment` objects, so they are the alignments the final scores and subgradient come from, ties included. Recovering the paths takes one extra alignment pass at the end; pass `return_alignments=False` to skip it. The backends agree up to tie-breaking. When two alignments of a pair score exactly the same but use different substitutions or gaps, the backends may pick different ones. Both are valid subgradients, but the fits then drift apart. Exact ties are common with integer-valued scores, such as the default baseline aligner, and with fitted substitution matrices that contain exactly equal entries, such as the zeros a ridge fit assigns to substitutions that never occur in the data. Away from ties, the two backends follow the same trajectory up to floating-point rounding. From 897541c263f481bb6b3099166475f1197c015f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Mon, 5 Oct 2026 18:29:42 +0200 Subject: [PATCH 61/65] logit_logL from the logits: sum(y*z) - sum(logaddexp(0, z)), no clipping, raises on non-finite; both backends use it --- TODO-nwgrad.md | 4 -- benchmarks/bench_backends.py | 4 +- src/discrimalign.py | 17 ++++----- src/logit_link.py | 42 ++++++++++----------- src/nwgrad_backend.py | 13 +++++-- tests/helpers.py | 3 +- tests/test_discrimalign.py | 8 ++-- tests/test_logit_link.py | 73 ++++++++++++++++++++++-------------- 8 files changed, 89 insertions(+), 75 deletions(-) diff --git a/TODO-nwgrad.md b/TODO-nwgrad.md index 311e997..739bf4d 100644 --- a/TODO-nwgrad.md +++ b/TODO-nwgrad.md @@ -19,10 +19,6 @@ they become relevant. simple-mode initial estimators in `src/optimization.py`. They will stop working; the replacement is `C=np.inf`. It is deprecated, with a warning, since 1.8. -- **`logit_logL` precision** (`src/logit_link.py`): it computes `log(p)` and - `log1p(-p)` from `p = expit(z)`, which loses precision on confident - predictions, and clips each pair's loss at about 36. A stable form computes - `sum(y*z - logaddexp(0, z))` from the logits. - **`src.discrimalign` is shadowed:** `src/__init__.py` imports the function under the module's name, so `import src.discrimalign` binds the function. Use `sys.modules["src.discrimalign"]` to reach the module. diff --git a/benchmarks/bench_backends.py b/benchmarks/bench_backends.py index a2343d2..1402516 100644 --- a/benchmarks/bench_backends.py +++ b/benchmarks/bench_backends.py @@ -164,7 +164,7 @@ def make_aligner(mode, params): # --- timing ------------------------------------------------------------------- def fit_alpha(scores, labels, alpha0): - target = lambda a: -logit_logL(logit_partial_scores(scores, a), labels) + target = lambda a: -logit_logL(scores, a[0], labels) fprime = lambda a: -np.sum(labels - logit_partial_scores(scores, a)) return minimize(target, alpha0, jac=fprime)["x"][0] @@ -177,7 +177,7 @@ def time_iterations(engine, params, labels, reps): engine.set_params(params) scores = engine.scores() t1 = time.perf_counter() - logit_logL(logit_partial_scores(scores, params["alpha"]), labels) + logit_logL(scores, params["alpha"], labels) t2 = time.perf_counter() alpha = fit_alpha(scores, labels, params["alpha"]) t3 = time.perf_counter() diff --git a/src/discrimalign.py b/src/discrimalign.py index d6be819..382882f 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -135,7 +135,7 @@ def raw_subgradient(self, logit_scores, labels, alpha): return logit_subgradient(self.alignments, logit_scores, labels, alpha, self.alphabet) -def _python_logistic_step(engine, updated_parameters, labels, labels_float, one_minus_labels, +def _python_logistic_step(engine, updated_parameters, labels_float, alpha_solver, loglik_trajectory, verbose): """ Align, then one iteration's logistic work in numpy: the log-likelihood at @@ -145,7 +145,8 @@ def _python_logistic_step(engine, updated_parameters, labels, labels_float, one_ alignment_scores = np.asarray(engine.scores(), dtype=float) logit_scores = logit_partial_scores(alignment_scores, updated_parameters['alpha']) - new_logL = _logit_logL_unchecked(logit_scores, labels_float, one_minus_labels) + new_logL = _logit_logL_unchecked(alignment_scores, updated_parameters['alpha'], + labels_float) loglik_trajectory.append(new_logL) if verbose: print("Current alpha:", updated_parameters['alpha']) @@ -166,12 +167,11 @@ def _python_logistic_step(engine, updated_parameters, labels, labels_float, one_ logit_scores0=logit_scores) else: def alpha_target(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return -logit_logL(logit_scores, labels) + return -_logit_logL_unchecked(alignment_scores, alpha[0], labels_float) def alpha_fprime(alpha): logit_scores = logit_partial_scores(alignment_scores, alpha) - return -np.sum(labels - logit_scores) + return -np.sum(labels_float - logit_scores) new_alpha = minimize(alpha_target, updated_parameters['alpha'], @@ -179,7 +179,7 @@ def alpha_fprime(alpha): logit_scores = logit_partial_scores(alignment_scores, new_alpha) if verbose: - new_logL = _logit_logL_unchecked(logit_scores, labels_float, one_minus_labels) + new_logL = _logit_logL_unchecked(alignment_scores, new_alpha, labels_float) print("Updated alpha:", new_alpha) print('Updated logL:', new_logL) @@ -341,7 +341,6 @@ def add_noise(shape, niter): labels_float = np.asarray(labels, dtype=float) if not np.isin(labels_float, [0, 1]).all(): raise ValueError('Labels can only be 0 or 1') - one_minus_labels = 1 - labels_float # The nwgrad engine does the logistic part of each iteration in C++ (the # same alpha fit, same probabilities); the Biopython engine has no such step. use_logistic_step = (alpha_solver == 'safeguarded_newton' @@ -369,7 +368,7 @@ def add_noise(shape, niter): updated_parameters['alpha'] = new_alpha else: new_alpha, subgradient = _python_logistic_step( - engine, updated_parameters, labels, labels_float, one_minus_labels, + engine, updated_parameters, labels_float, alpha_solver, loglik_trajectory, verbose) updated_parameters['alpha'] = new_alpha if subgradient_scale != 1.0: @@ -463,7 +462,7 @@ def add_noise(shape, niter): alnlist = engine.alignments logit_scores = logit_partial_scores(alignment_scores, updated_parameters['alpha']) - new_logL = logit_logL(logit_scores, labels) + new_logL = logit_logL(alignment_scores, updated_parameters['alpha'], labels) ## EL = 0 ## VL = 0 ## for ls in logit_scores: diff --git a/src/logit_link.py b/src/logit_link.py index b38b4f5..c2df80f 100644 --- a/src/logit_link.py +++ b/src/logit_link.py @@ -17,35 +17,36 @@ def logit_partial_scores(alignment_scores, alpha): return expit(alpha + alignment_scores) -def logit_logL(logit_scores, labels): +def logit_logL(alignment_scores, alpha, labels): """ - Calculate the log likelihood in the logistic model. - logit_scores are a list of values of the logistic function - of the alignment score, calculated with logit_partial_scores. - labels are a 1D numpy array with values 0 or 1. + Calculate the log likelihood in the logistic model: + sum_i y_i z_i - log(1 + e^{z_i}), with z_i = alpha + alignment_scores[i]. + Computed from the logits rather than from the probabilities, so it needs + no clipping and loses no precision on confident predictions, and + log(1 + e^z) is evaluated without overflow. + labels are a 1D array with values 0 or 1. + Raises FloatingPointError if the result is not finite. """ - logit_scores = np.asarray(logit_scores, dtype=float) labels = np.asarray(labels) if not np.isin(labels, [0, 1]).all(): raise ValueError('Labels can only be 0 or 1') - return _logit_logL_unchecked(logit_scores, labels, 1 - labels) + return _logit_logL_unchecked(np.asarray(alignment_scores, dtype=float), alpha, + labels.astype(float)) -def _logit_logL_unchecked(logit_scores, labels, one_minus_labels): +def _logit_logL_unchecked(alignment_scores, alpha, labels): """ - logit_logL() for a float array of logit scores and labels already known to - be 0 or 1, given 1 - labels too: the same arithmetic in the same order, - with fewer temporary arrays. For loops that evaluate it on the same labels + logit_logL() for a float array of alignment scores and float labels + already known to be 0 or 1. For loops that evaluate it on the same labels many times. """ - eps = np.finfo(float).eps - clipped = np.clip(logit_scores, eps, 1.0 - eps) - terms = np.log(clipped) - terms *= labels - other = np.log1p(np.negative(clipped, out=clipped)) - other *= one_minus_labels - terms += other - return float(np.sum(terms)) + z = alignment_scores + alpha + with np.errstate(invalid='ignore'): # a non-finite result raises below + logL = float(np.dot(labels, z) - np.sum(np.logaddexp(0.0, z))) + if not np.isfinite(logL): + raise FloatingPointError(f'Log-likelihood is {logL} at alpha={alpha}: ' + 'alignment scores or alpha are not finite') + return logL def dlda(logit_scores, labels): @@ -77,8 +78,7 @@ def fit_alpha(alignment_scores, labels, alpha0, tol=1e-12, max_newton=8, maxiter alpha0 is the previous iteration's alpha. Otherwise the root is bracketed and found by Newton steps inside the bracket, with bisection whenever a Newton step would leave it or converge too slowly (Numerical Recipes' - rtsafe). The log-likelihood itself is never evaluated: logit_logL clips - probabilities, which makes line searches on it unreliable. + rtsafe). The log-likelihood itself is never evaluated. logit_scores0, if given, must be logit_partial_scores(alignment_scores, alpha0); a caller that already has them saves one pass over the data. diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index 594d084..a861283 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -8,6 +8,7 @@ import nwgrad.logistic as nwgrad_logistic from Bio.Align import substitution_matrices +from .logit_link import _logit_logL_unchecked from .nwgrad_params import gap_counts, grad_to_raw, to_nwgrad from .optimization import CountArrays, EmptyLocalAlignment @@ -129,14 +130,18 @@ def scores(self): def logistic_step(self, labels, alpha0): """ - Align, then one iteration's logistic work in nwgrad (C++, parallel): - the log-likelihood at alpha0, the fitted alpha (as fit_alpha()), and - the raw subgradient at that alpha, in logit_subgradient's format. + Align, then one iteration's logistic work: the log-likelihood at + alpha0, the fitted alpha (as fit_alpha()), and the raw subgradient at + that alpha, in logit_subgradient's format. The alpha fit and the + subgradient run in nwgrad (C++, parallel). The log-likelihood comes from + logit_logL's formula on the cached scores instead of nwgrad's, which + clips probabilities. labels: float64 array of 0s and 1s. """ self.batch.score_and_grad() step = nwgrad_logistic.step(self.batch, labels, alpha0) - return step.loglik_at_alpha0, step.alpha, grad_to_raw(step.grad) + loglik = _logit_logL_unchecked(self.batch.scores(), alpha0, labels) + return loglik, step.alpha, grad_to_raw(step.grad) def raw_counts(self): """Per-pair counts in logit_subgradient's format, for the scores just computed.""" diff --git a/tests/helpers.py b/tests/helpers.py index c9d3604..d2b4e0b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -117,8 +117,7 @@ def loglik_at(seqsA, seqsB, labels, mode, params): Log-likelihood at params, with alpha held fixed at params['alpha']. Computed from the logits as sum(y*z - log(1 + e^z)), independently of - logit_logL, and without the cancellation in 1 - expit(z) that makes - logit_logL too noisy for finite differences on confident predictions. + logit_logL. """ z = params["alpha"] + scores_at(seqsA, seqsB, mode, params) labels = np.asarray(labels, dtype=float) diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index ebcbafb..c9aef97 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -171,7 +171,7 @@ def test_final_outputs_are_consistent(mode, gap_mode, substitution_mode, backend tol = 0 if backend == "biopython" else 1e-12 np.testing.assert_allclose(scores, [a.score for a in realigned], rtol=tol, atol=tol) np.testing.assert_allclose(res["alignment_logit_scores"], expit(res["alpha"] + scores), rtol=1e-14) - assert res["final_loglik"] == pytest.approx(logit_logL(res["alignment_logit_scores"], y), rel=1e-14) + assert res["final_loglik"] == pytest.approx(logit_logL(scores, res["alpha"], y), rel=1e-14) # The returned aligner carries the returned parameters. reference = make_aligner(mode, {k: res[k] for k in _param_keys(gap_mode, substitution_mode)}) assert [a.score for a in realigned] == [a.score for a in align_all(A, B, reference)] @@ -198,7 +198,7 @@ def test_zero_iterations_return_initial_parameters(mode, gap_mode, substitution_ assert res["alpha"] == p0["alpha"] assert res["subgradient_l2_trajectory"] == [] scores = np.array([a.score for a in align_all(A, B, make_aligner(mode, p0))]) - assert res["final_loglik"] == pytest.approx(logit_logL(expit(p0["alpha"] + scores), y)) + assert res["final_loglik"] == pytest.approx(logit_logL(scores, p0["alpha"], y)) @pytest.mark.parametrize("backend", BACKENDS) @@ -243,7 +243,7 @@ def test_one_iteration_is_one_subgradient_step(mode, gap_mode, substitution_mode norm = np.sqrt(sum(np.sum(np.asarray(g) ** 2) for g in grad.values())) assert res["subgradient_l2_trajectory"][0] == pytest.approx(norm, rel=1e-12) - assert res["loglik_trajectory"][0] == pytest.approx(logit_logL(expit(p0["alpha"] + scores), y)) + assert res["loglik_trajectory"][0] == pytest.approx(logit_logL(scores, p0["alpha"], y)) @pytest.mark.parametrize("backend", BACKENDS) @@ -260,7 +260,7 @@ def test_small_step_increases_likelihood_at_fixed_alpha(mode, backend): def loglik(p): s = np.array([a.score for a in align_all(A, B, make_aligner(mode, p))]) - return logit_logL(expit(p["alpha"] + s), y) + return logit_logL(s, p["alpha"], y) assert loglik(p1) > loglik(p0_fitted) diff --git a/tests/test_logit_link.py b/tests/test_logit_link.py index 9ce5909..3c27d75 100644 --- a/tests/test_logit_link.py +++ b/tests/test_logit_link.py @@ -2,6 +2,7 @@ import numpy as np import pytest from Bio.Align import PairwiseAligner +from scipy.special import expit from src.logit_link import (d2lda2, dlda, fit_alpha, logit_logL, logit_partial_scores, logit_subgradient) @@ -53,55 +54,83 @@ def test_partial_scores_empty_input(): # --- logit_logL ------------------------------------------------------------- def test_logL_matches_bernoulli_formula(): - p = np.array([0.1, 0.4, 0.8, 0.95]) + scores = np.array([-2.2, -0.4, 1.4, 2.9]) + alpha = 0.1 y = np.array([0, 1, 1, 0]) + p = expit(alpha + scores) expected = np.sum(y * np.log(p) + (1 - y) * np.log(1 - p)) - assert logit_logL(p, y) == pytest.approx(expected, rel=1e-14) + assert logit_logL(scores, alpha, y) == pytest.approx(expected, rel=1e-14) def test_logL_returns_python_float(): - assert type(logit_logL([0.3], [1])) is float + assert type(logit_logL([0.3], 0.0, [1])) is float def test_logL_is_nonpositive(): rng = np.random.default_rng(0) - p = rng.uniform(0.01, 0.99, size=50) + scores = rng.normal(scale=4, size=50) y = rng.integers(0, 2, size=50) - assert logit_logL(p, y) <= 0.0 + assert logit_logL(scores, 0.5, y) <= 0.0 def test_logL_perfect_prediction_is_near_zero(): - assert logit_logL([1.0, 0.0], [1, 0]) == pytest.approx(0.0, abs=1e-12) + assert logit_logL([50.0, -50.0], 0.0, [1, 0]) == pytest.approx(0.0, abs=1e-12) -def test_logL_clips_certain_wrong_predictions_to_finite_value(): - value = logit_logL([0.0, 1.0], [1, 0]) - assert np.isfinite(value) - assert value < -60 # 2 * log(eps) is about -72 +def test_logL_is_exact_on_certain_wrong_predictions(): + # Probabilities would round to 0 and 1 here; the logits keep the exact value. + assert logit_logL([-800.0, 800.0], 0.0, [1, 0]) == -1600.0 + + +def test_logL_keeps_precision_on_confident_right_predictions(): + # log(1 - p) with p = expit(-40) would round to 0; the exact value is about -4e-18. + assert logit_logL([40.0], 0.0, [1]) == pytest.approx(-np.log1p(np.exp(-40.0)), rel=1e-14) + + +@pytest.mark.parametrize("scores,alpha", [([0.0, np.nan], 0.0), ([0.0, np.inf], 0.0), + ([0.0, 1.0], -np.inf), ([0.0, 1.0], np.nan)]) +def test_logL_raises_on_non_finite_input(scores, alpha): + with pytest.raises(FloatingPointError): + logit_logL(scores, alpha, [0, 1]) def test_logL_accepts_lists(): - assert logit_logL([0.25, 0.75], [0, 1]) == pytest.approx(2 * np.log(0.75)) + assert logit_logL([-1.0, 1.0], 0.0, [0, 1]) == pytest.approx(2 * np.log(expit(1.0))) def test_logL_accepts_boolean_labels(): - assert logit_logL([0.25, 0.75], np.array([False, True])) == pytest.approx(2 * np.log(0.75)) + assert logit_logL([-1.0, 1.0], 0.0, np.array([False, True])) == pytest.approx( + 2 * np.log(expit(1.0))) + + +def test_logL_adds_alpha_to_every_score(): + scores = np.array([-1.5, 0.2, 3.0]) + y = [0, 1, 1] + assert logit_logL(scores, 0.7, y) == logit_logL(scores + 0.7, 0.0, y) @pytest.mark.parametrize("labels", [[0, 2], [-1, 1], [0.5, 1]]) def test_logL_rejects_non_binary_labels(labels): with pytest.raises(ValueError): - logit_logL([0.3, 0.6], labels) + logit_logL([0.3, 0.6], 0.0, labels) def test_logL_empty_is_zero(): - assert logit_logL([], []) == 0.0 + assert logit_logL([], 0.0, []) == 0.0 + + +def test_logL_unchecked_is_bit_identical_to_logL(): + from src.logit_link import _logit_logL_unchecked + rng = np.random.default_rng(4) + scores = rng.normal(scale=20, size=1000) + labels = rng.integers(0, 2, 1000) + assert _logit_logL_unchecked(scores, -0.3, labels.astype(float)) == logit_logL(scores, -0.3, labels) # --- dlda and d2lda2 -------------------------------------------------------- def _logL_of_alpha(alpha, scores, labels): - return logit_logL(logit_partial_scores(scores, alpha), labels) + return logit_logL(scores, alpha, labels) @pytest.mark.parametrize("seed", range(5)) @@ -189,20 +218,6 @@ def test_fit_alpha_given_starting_probabilities_is_bit_identical(start): assert given == expected -def test_logL_shared_arithmetic_is_bit_identical_to_the_formula(): - from src.logit_link import _logit_logL_unchecked - rng = np.random.default_rng(4) - p = rng.random(1000) - p[:5] = [0.0, 1.0, 1e-300, 1 - 1e-17, 0.5] - labels = rng.integers(0, 2, 1000) - eps = np.finfo(float).eps - clipped = np.clip(p, eps, 1.0 - eps) - formula = float(np.sum(labels * np.log(clipped) + (1 - labels) * np.log1p(-clipped))) - as_float = labels.astype(float) - assert logit_logL(p, labels) == formula - assert _logit_logL_unchecked(p, as_float, 1 - as_float) == formula - - @pytest.mark.parametrize("labels", [[1, 1, 1], [0, 0, 0]]) def test_fit_alpha_needs_both_classes(labels): with pytest.raises(ValueError, match="both classes"): From 14e93ae3bece66ad3f4c4886b119428a7882bdc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Mon, 5 Oct 2026 18:56:54 +0200 Subject: [PATCH 62/65] Check labels once at the start of discrimalign(); the loop fits alpha unchecked --- src/discrimalign.py | 22 ++++++++++++++-------- src/logit_link.py | 11 ++++++++++- tests/test_discrimalign.py | 32 +++++++++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 382882f..a9d5e44 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -11,7 +11,7 @@ import os from .optimization import (create_powerstep, get_initial_estimate, get_initial_estimate_from_counts, get_first_alignment) -from .logit_link import (_logit_logL_unchecked, fit_alpha, logit_partial_scores, logit_logL, +from .logit_link import (_fit_alpha_unchecked, _logit_logL_unchecked, logit_partial_scores, logit_subgradient) @@ -163,8 +163,9 @@ def _python_logistic_step(engine, updated_parameters, labels_float, # Optimize the logistic intercept (alpha) if alpha_solver == 'safeguarded_newton': - new_alpha = fit_alpha(alignment_scores, labels_float, updated_parameters['alpha'], - logit_scores0=logit_scores) + new_alpha = _fit_alpha_unchecked(alignment_scores, labels_float, + updated_parameters['alpha'], + logit_scores0=logit_scores) else: def alpha_target(alpha): return -_logit_logL_unchecked(alignment_scores, alpha[0], labels_float) @@ -248,6 +249,14 @@ def discrimalign(seqlistA, seqlistB, if empty: raise ValueError(f'{name} contains empty sequences (at indices {empty[:10]}); ' 'every sequence needs at least one residue') + # The labels are checked once, here; the iterations use them as floats unchecked. + labels_float = np.asarray(labels, dtype=float) + if not np.isin(labels_float, [0, 1]).all(): + raise ValueError('Labels can only be 0 or 1') + positives = np.sum(labels_float) + if positives == 0 or positives == len(labels_float): + raise ValueError('Labels of both classes are needed: with one class the ' + 'likelihood has no finite maximum') if num_threads == 0 and backend == 'biopython': num_threads = 1 elif num_threads == 0: @@ -337,10 +346,6 @@ def add_noise(shape, niter): _clip_gap_scores(updated_parameters, gap_mode) _configure_aligner(aligner, updated_parameters, gap_mode, substitution_mode) - # The loop uses the labels as floats, checked once. - labels_float = np.asarray(labels, dtype=float) - if not np.isin(labels_float, [0, 1]).all(): - raise ValueError('Labels can only be 0 or 1') # The nwgrad engine does the logistic part of each iteration in C++ (the # same alpha fit, same probabilities); the Biopython engine has no such step. use_logistic_step = (alpha_solver == 'safeguarded_newton' @@ -462,7 +467,8 @@ def add_noise(shape, niter): alnlist = engine.alignments logit_scores = logit_partial_scores(alignment_scores, updated_parameters['alpha']) - new_logL = logit_logL(alignment_scores, updated_parameters['alpha'], labels) + new_logL = _logit_logL_unchecked(np.asarray(alignment_scores, dtype=float), + updated_parameters['alpha'], labels_float) ## EL = 0 ## VL = 0 ## for ls in logit_scores: diff --git a/src/logit_link.py b/src/logit_link.py index c2df80f..456c4da 100644 --- a/src/logit_link.py +++ b/src/logit_link.py @@ -83,13 +83,22 @@ def fit_alpha(alignment_scores, labels, alpha0, tol=1e-12, max_newton=8, maxiter logit_scores0, if given, must be logit_partial_scores(alignment_scores, alpha0); a caller that already has them saves one pass over the data. """ - alignment_scores = np.asarray(alignment_scores, dtype=float) labels = np.asarray(labels, dtype=float) positives = np.sum(labels) if positives == 0 or positives == len(labels): raise ValueError('Fitting alpha needs labels of both classes: with one class ' 'the likelihood has no finite maximum') + return _fit_alpha_unchecked(np.asarray(alignment_scores, dtype=float), labels, alpha0, + tol, max_newton, maxiter, logit_scores0) + +def _fit_alpha_unchecked(alignment_scores, labels, alpha0, tol=1e-12, max_newton=8, + maxiter=200, logit_scores0=None): + """ + fit_alpha() for float arrays of scores and labels, the labels already + known to be 0 or 1 with both classes present. For loops that fit alpha on + the same labels many times. + """ def derivatives(alpha, logit_scores=None): if logit_scores is None: logit_scores = logit_partial_scores(alignment_scores, alpha) diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index c9aef97..992ef57 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -136,6 +136,32 @@ def test_single_class_labels_without_initial_parameters_raise(backend): _run_on(backend, A, B, np.ones(len(A), dtype=int), "local", "affine", "simple") +@pytest.mark.parametrize("backend", BACKENDS) +@pytest.mark.parametrize("value", [0, 1]) +def test_single_class_labels_raise_before_any_work(backend, value, monkeypatch): + """Checked once at the start: also with a warm start, which skips the initial + estimate, and before anything is aligned.""" + rng, A, B, _ = _data() + p0 = random_params(rng, "affine", "simple") + module = sys.modules["src.discrimalign"] + monkeypatch.setattr(module, "_align_pairs", lambda *a, **k: pytest.fail("aligned")) + with pytest.raises(ValueError, match="both classes"): + _run_on(backend, A, B, np.full(len(A), value), "local", "affine", "simple", + initial_parameters=p0) + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_non_binary_labels_raise_before_any_work(backend, monkeypatch): + rng, A, B, y = _data() + p0 = random_params(rng, "affine", "simple") + y = np.array(y, dtype=float) + y[0] = 2 + module = sys.modules["src.discrimalign"] + monkeypatch.setattr(module, "_align_pairs", lambda *a, **k: pytest.fail("aligned")) + with pytest.raises(ValueError, match="0 or 1"): + _run_on(backend, A, B, y, "local", "affine", "simple", initial_parameters=p0) + + # --- result structure ------------------------------------------------------- @pytest.mark.parametrize("backend", BACKENDS) @@ -555,18 +581,18 @@ def test_discrimalign_uses_default_stepfunction(): def _record_alpha_fits(monkeypatch): """ Record (scores, labels, alpha) for every alpha fit: discrimalign's Python - fit_alpha, and nwgrad.logistic.step where the nwgrad backend uses it. + _fit_alpha_unchecked, and nwgrad.logistic.step where the nwgrad backend uses it. """ module = sys.modules["src.discrimalign"] calls = [] - original = module.fit_alpha + original = module._fit_alpha_unchecked def recording(scores, labels, alpha0, **kwargs): alpha = original(scores, labels, alpha0, **kwargs) calls.append((np.array(scores, dtype=float), np.array(labels, dtype=float), alpha)) return alpha - monkeypatch.setattr(module, "fit_alpha", recording) + monkeypatch.setattr(module, "_fit_alpha_unchecked", recording) backend = importlib.import_module("src.nwgrad_backend") logistic = backend.nwgrad_logistic From e4368e799a220d0885e9990c2975c2c9dbfe0fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Mon, 5 Oct 2026 19:05:00 +0200 Subject: [PATCH 63/65] One logistic step for both engines: engine.logistic_step() in every iteration, numpy path in logit_link.logistic_step --- src/discrimalign.py | 90 ++++++-------------------------------- src/logit_link.py | 29 ++++++++++++ src/nwgrad_backend.py | 17 ++++--- tests/test_discrimalign.py | 8 ++-- 4 files changed, 58 insertions(+), 86 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index a9d5e44..4a5fe90 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -5,13 +5,12 @@ from Bio.Align import PairwiseAligner, substitution_matrices import numpy as np from numpy import random as rd -from scipy.optimize import minimize from copy import deepcopy from math import ceil import os from .optimization import (create_powerstep, get_initial_estimate, get_initial_estimate_from_counts, get_first_alignment) -from .logit_link import (_fit_alpha_unchecked, _logit_logL_unchecked, logit_partial_scores, +from .logit_link import (_logit_logL_unchecked, logistic_step, logit_partial_scores, logit_subgradient) @@ -134,59 +133,9 @@ def scores(self): def raw_subgradient(self, logit_scores, labels, alpha): return logit_subgradient(self.alignments, logit_scores, labels, alpha, self.alphabet) - -def _python_logistic_step(engine, updated_parameters, labels_float, - alpha_solver, loglik_trajectory, verbose): - """ - Align, then one iteration's logistic work in numpy: the log-likelihood at - the current alpha (appended to loglik_trajectory), the alpha fit, and the - raw subgradient at the new alpha. Returns (new_alpha, subgradient). - """ - alignment_scores = np.asarray(engine.scores(), dtype=float) - logit_scores = logit_partial_scores(alignment_scores, - updated_parameters['alpha']) - new_logL = _logit_logL_unchecked(alignment_scores, updated_parameters['alpha'], - labels_float) - loglik_trajectory.append(new_logL) - if verbose: - print("Current alpha:", updated_parameters['alpha']) - print('Current logL:', new_logL) -## EL = 0 -## VL = 0 -## for ls in logit_scores: -## if 1e-30 < ls < 1-1e-30: -## EL += ls*np.log(ls) + (1-ls)*np.log(1-ls) -## VL += ls*(1-ls)*(np.log(ls)**2 + np.log(1-ls)**2) -## SDL = np.sqrt(VL) -## loglik_expectation.append(EL) -## loglik_sd.append(SDL) - - # Optimize the logistic intercept (alpha) - if alpha_solver == 'safeguarded_newton': - new_alpha = _fit_alpha_unchecked(alignment_scores, labels_float, - updated_parameters['alpha'], - logit_scores0=logit_scores) - else: - def alpha_target(alpha): - return -_logit_logL_unchecked(alignment_scores, alpha[0], labels_float) - - def alpha_fprime(alpha): - logit_scores = logit_partial_scores(alignment_scores, alpha) - return -np.sum(labels_float - logit_scores) - - new_alpha = minimize(alpha_target, - updated_parameters['alpha'], - jac=alpha_fprime)['x'][0] - - logit_scores = logit_partial_scores(alignment_scores, new_alpha) - if verbose: - new_logL = _logit_logL_unchecked(alignment_scores, new_alpha, labels_float) - print("Updated alpha:", new_alpha) - print('Updated logL:', new_logL) - - # The subgradient at the new alpha - subgradient = engine.raw_subgradient(logit_scores, labels_float, new_alpha) - return new_alpha, subgradient + def logistic_step(self, labels, alpha0, alpha_solver): + """Align, then one iteration's logistic work in numpy; see logit_link.logistic_step.""" + return logistic_step(self, labels, alpha0, alpha_solver) def discrimalign(seqlistA, seqlistB, @@ -346,11 +295,6 @@ def add_noise(shape, niter): _clip_gap_scores(updated_parameters, gap_mode) _configure_aligner(aligner, updated_parameters, gap_mode, substitution_mode) - # The nwgrad engine does the logistic part of each iteration in C++ (the - # same alpha fit, same probabilities); the Biopython engine has no such step. - use_logistic_step = (alpha_solver == 'safeguarded_newton' - and hasattr(engine, 'logistic_step')) - # Subgradient refinement loglik_trajectory = [] subgradient_l2_trajectory = [] @@ -359,23 +303,17 @@ def add_noise(shape, niter): for iternb in range(max_iter): if verbose: print('Start of iteration', iternb) - # Realign with the new parameters + # Realign with the new parameters, then the log-likelihood at the + # current alpha, the alpha fit and the subgradient at the new alpha. engine.set_params(updated_parameters) - if use_logistic_step: - # Alignment, log-likelihood, alpha fit and subgradient in nwgrad. - new_logL, new_alpha, subgradient = engine.logistic_step( - labels_float, updated_parameters['alpha']) - loglik_trajectory.append(new_logL) - if verbose: - print("Current alpha:", updated_parameters['alpha']) - print('Current logL:', new_logL) - print("Updated alpha:", new_alpha) - updated_parameters['alpha'] = new_alpha - else: - new_alpha, subgradient = _python_logistic_step( - engine, updated_parameters, labels_float, - alpha_solver, loglik_trajectory, verbose) - updated_parameters['alpha'] = new_alpha + new_logL, new_alpha, subgradient = engine.logistic_step( + labels_float, updated_parameters['alpha'], alpha_solver) + loglik_trajectory.append(new_logL) + if verbose: + print("Current alpha:", updated_parameters['alpha']) + print('Current logL:', new_logL) + print("Updated alpha:", new_alpha) + updated_parameters['alpha'] = new_alpha if subgradient_scale != 1.0: subgradient['Gap opens'] *= subgradient_scale subgradient['Gap extends'] *= subgradient_scale diff --git a/src/logit_link.py b/src/logit_link.py index 456c4da..3fb1671 100644 --- a/src/logit_link.py +++ b/src/logit_link.py @@ -5,6 +5,7 @@ import numpy as np from Bio.Align import substitution_matrices +from scipy.optimize import minimize from scipy.special import expit @@ -154,6 +155,34 @@ def derivatives(alpha, logit_scores=None): raise RuntimeError('fit_alpha: did not converge') +def logistic_step(engine, labels, alpha0, alpha_solver='safeguarded_newton'): + """ + One iteration's logistic work on an engine whose parameters are set: + align (engine.scores()), the log-likelihood at alpha0, the alpha that + maximises the likelihood for these scores, and engine.raw_subgradient() at + that alpha. Returns (loglik_at_alpha0, alpha, subgradient). + + labels: float array of 0s and 1s with both classes present, unchecked. + alpha_solver: 'safeguarded_newton' (fit_alpha) or 'bfgs' (scipy's minimize + on the log-likelihood). + """ + alignment_scores = np.asarray(engine.scores(), dtype=float) + loglik = _logit_logL_unchecked(alignment_scores, alpha0, labels) + if alpha_solver == 'safeguarded_newton': + alpha = _fit_alpha_unchecked(alignment_scores, labels, alpha0, + logit_scores0=logit_partial_scores(alignment_scores, alpha0)) + else: + def alpha_target(alpha): + return -_logit_logL_unchecked(alignment_scores, alpha[0], labels) + + def alpha_fprime(alpha): + return -np.sum(labels - logit_partial_scores(alignment_scores, alpha)) + + alpha = minimize(alpha_target, alpha0, jac=alpha_fprime)['x'][0] + logit_scores = logit_partial_scores(alignment_scores, alpha) + return loglik, alpha, engine.raw_subgradient(logit_scores, labels, alpha) + + def logit_subgradient(alignment_list, logit_scores, labels, alpha, alphabet): diff --git a/src/nwgrad_backend.py b/src/nwgrad_backend.py index a861283..a86ed6f 100644 --- a/src/nwgrad_backend.py +++ b/src/nwgrad_backend.py @@ -8,7 +8,7 @@ import nwgrad.logistic as nwgrad_logistic from Bio.Align import substitution_matrices -from .logit_link import _logit_logL_unchecked +from .logit_link import _logit_logL_unchecked, logistic_step from .nwgrad_params import gap_counts, grad_to_raw, to_nwgrad from .optimization import CountArrays, EmptyLocalAlignment @@ -128,16 +128,19 @@ def scores(self): self.batch.score_and_grad() return self.batch.scores() - def logistic_step(self, labels, alpha0): + def logistic_step(self, labels, alpha0, alpha_solver): """ Align, then one iteration's logistic work: the log-likelihood at - alpha0, the fitted alpha (as fit_alpha()), and the raw subgradient at - that alpha, in logit_subgradient's format. The alpha fit and the - subgradient run in nwgrad (C++, parallel). The log-likelihood comes from - logit_logL's formula on the cached scores instead of nwgrad's, which - clips probabilities. + alpha0, the fitted alpha, and the raw subgradient at that alpha, in + logit_subgradient's format; see logit_link.logistic_step. With + alpha_solver='safeguarded_newton', the alpha fit (as fit_alpha()) and + the subgradient run in nwgrad (C++, parallel), and the log-likelihood + comes from logit_logL's formula on the cached scores instead of + nwgrad's, which clips probabilities. Other solvers run in numpy. labels: float64 array of 0s and 1s. """ + if alpha_solver != 'safeguarded_newton': + return logistic_step(self, labels, alpha0, alpha_solver) self.batch.score_and_grad() step = nwgrad_logistic.step(self.batch, labels, alpha0) loglik = _logit_logL_unchecked(self.batch.scores(), alpha0, labels) diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 992ef57..5aa16e1 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -583,7 +583,7 @@ def _record_alpha_fits(monkeypatch): Record (scores, labels, alpha) for every alpha fit: discrimalign's Python _fit_alpha_unchecked, and nwgrad.logistic.step where the nwgrad backend uses it. """ - module = sys.modules["src.discrimalign"] + module = sys.modules["src.logit_link"] calls = [] original = module._fit_alpha_unchecked @@ -665,8 +665,10 @@ def test_nwgrad_logistic_step_matches_the_python_path(mode, gap_mode, substituti _, A, B, y = _data(24) kwargs = dict(max_iter=5, stepfunction=create_constant_step(0.01)) native = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, **kwargs) - # Without logistic_step, discrimalign() runs the numpy path on the nwgrad engine. - monkeypatch.delattr(backend.NwgradEngine, "logistic_step") + # The numpy path (logit_link.logistic_step) on the nwgrad engine. + monkeypatch.setattr(backend.NwgradEngine, "logistic_step", + lambda self, labels, alpha0, alpha_solver: + backend.logistic_step(self, labels, alpha0, alpha_solver)) python = _run_on("nwgrad", A, B, y, mode, gap_mode, substitution_mode, **kwargs) assert native["alpha"] == pytest.approx(python["alpha"], rel=1e-12, abs=1e-12) np.testing.assert_allclose(native["loglik_trajectory"], python["loglik_trajectory"], rtol=1e-12) From 96995278e9be0d824eb226e7aa8753a58a6d2d6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Mon, 5 Oct 2026 19:13:22 +0200 Subject: [PATCH 64/65] Resolve the alphabet in one helper and return it as results['alphabet'] --- src/discrimalign.py | 26 +++++++++++++++++++------- tests/test_discrimalign.py | 17 ++++++++++++++++- tests/test_discrimalign_helpers.py | 20 +++++++++++++++++++- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/discrimalign.py b/src/discrimalign.py index 4a5fe90..4f352f7 100644 --- a/src/discrimalign.py +++ b/src/discrimalign.py @@ -74,6 +74,22 @@ def _warm_start_alphabet(baseline_aligner=None, initial_parameters=None): return None +def _resolve_alphabet(alphabet, seqlistA, seqlistB, baseline_aligner=None, + initial_parameters=None): + """ + The alphabet of the fit: the one given; else a warm start's (the + substitution matrix of initial_parameters, then of baseline_aligner); else + the sorted characters of the sequences. + """ + if alphabet is not None: + return alphabet + alphabet = _warm_start_alphabet(baseline_aligner, initial_parameters) + if alphabet is not None: + return alphabet + return ''.join(sorted({char for seqlist in (seqlistA, seqlistB) + for seq in seqlist for char in seq})) + + def _configure_aligner(aligner, params, gap_mode, substitution_mode): if gap_mode == 'affine': aligner.open_gap_score = params['open_gap_score'] @@ -215,13 +231,8 @@ def discrimalign(seqlistA, seqlistB, # TODO: long-pair (e.g. protein) workflows can be faster on physical # cores once their DP tables outgrow the cache; see TODO.md in nwgrad. num_threads = os.cpu_count() or 1 - if alphabet is None: - alphabet = _warm_start_alphabet(baseline_aligner, initial_parameters) - if alphabet is None: - charsetA = set(char for seq in seqlistA for char in seq) - charsetB = set(char for seq in seqlistB for char in seq) - alphabet = charsetA | charsetB - alphabet = ''.join(sorted(alphabet)) + alphabet = _resolve_alphabet(alphabet, seqlistA, seqlistB, baseline_aligner, + initial_parameters) if verbose: print('Alphabet:') @@ -425,6 +436,7 @@ def add_noise(shape, niter): results['alignments'] = alnlist results['alignment_logit_scores'] = logit_scores results['alpha'] = updated_parameters['alpha'] + results['alphabet'] = alphabet # results['loglik_expectation_trajectory'] = loglik_expectation # results['loglik_sd_trajectory'] = loglik_sd return results diff --git a/tests/test_discrimalign.py b/tests/test_discrimalign.py index 5aa16e1..4c3e501 100644 --- a/tests/test_discrimalign.py +++ b/tests/test_discrimalign.py @@ -164,6 +164,21 @@ def test_non_binary_labels_raise_before_any_work(backend, monkeypatch): # --- result structure ------------------------------------------------------- +@pytest.mark.parametrize("backend", BACKENDS) +def test_inferred_alphabet_is_returned(backend): + _, A, B, y = _data() + res = _run_on(backend, A, B, y, "local", "affine", "simple", max_iter=1) + assert res["alphabet"] == "".join(sorted(set("".join(A) + "".join(B)))) + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_given_alphabet_is_returned(backend): + _, A, B, y = _data() + res = _run_on(backend, A, B, y, "local", "affine", "general", max_iter=1, alphabet="TGCA") + assert res["alphabet"] == "TGCA" + assert "".join(res["substitution_matrix"].alphabet) == "TGCA" + + @pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("mode, gap_mode, substitution_mode", ALL_MODES) def test_result_structure(mode, gap_mode, substitution_mode, backend): @@ -171,7 +186,7 @@ def test_result_structure(mode, gap_mode, substitution_mode, backend): res = _run_on(backend, A, B, y, mode, gap_mode, substitution_mode, max_iter=3) expected = _param_keys(gap_mode, substitution_mode) | { "loglik_trajectory", "subgradient_l2_trajectory", "final_loglik", - "aligner", "alignments", "alignment_logit_scores", "alpha"} + "aligner", "alignments", "alignment_logit_scores", "alpha", "alphabet"} assert set(res) == expected assert len(res["loglik_trajectory"]) == 4 assert len(res["subgradient_l2_trajectory"]) == 3 diff --git a/tests/test_discrimalign_helpers.py b/tests/test_discrimalign_helpers.py index fefece2..5555a56 100644 --- a/tests/test_discrimalign_helpers.py +++ b/tests/test_discrimalign_helpers.py @@ -4,7 +4,7 @@ from Bio.Align import PairwiseAligner, substitution_matrices from src.discrimalign import (_align_pair_chunk, _align_pairs, _matrix_alphabet, - _pair_chunks, _warm_start_alphabet) + _pair_chunks, _resolve_alphabet, _warm_start_alphabet) from src.optimization import EmptyLocalAlignment from tests.helpers import make_aligner, make_pairs, random_params @@ -115,3 +115,21 @@ def test_warm_start_alphabet_none_for_simple_scoring(): aligner.match_score = 2 assert _warm_start_alphabet(aligner, {"match_score": 2.0}) is None assert _warm_start_alphabet(None, None) is None + + +# --- _resolve_alphabet ------------------------------------------------------- + +def test_resolve_alphabet_given_wins(): + aligner = PairwiseAligner() + aligner.substitution_matrix = _matrix("TCAG") + assert _resolve_alphabet("ACGT", ["XY"], ["Z"], aligner, + {"substitution_matrix": _matrix("GATC")}) == "ACGT" + + +def test_resolve_alphabet_from_warm_start(): + assert _resolve_alphabet(None, ["XY"], ["Z"], None, + {"substitution_matrix": _matrix("GATC")}) == "GATC" + + +def test_resolve_alphabet_from_sequences_is_sorted_union(): + assert _resolve_alphabet(None, ["GAT", "TTA"], ["CAN"], None, {"match_score": 1.0}) == "ACGNT" From 2d8e4253db1a7d07d20eb4a138bfd9444cbfa74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Startek?= Date: Mon, 5 Oct 2026 19:26:04 +0200 Subject: [PATCH 65/65] README: behavior changes for the logL, label checks, logit_logL signature and results['alphabet'] --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bbf3237..4270eb2 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ print(result["final_loglik"]) print(result["alpha"]) ``` -The returned object contains the fitted aligner, learned alignment parameters, intercept, final log-likelihood, and optimization trajectories. +The returned object contains the fitted aligner, learned alignment parameters, intercept, alphabet, final log-likelihood, and optimization trajectories. If `stepfunction` is omitted, `discrimalign` uses a conservative default power step with scale `1e-4`. For inference on new sequence pairs, use `predict_pairs` with the fitted result: @@ -269,7 +269,11 @@ Compared with earlier versions of DiscrimAlign: - In `symmetric` and `general` substitution mode with linear gaps, the initial estimate fits one coefficient on the number of gap columns. It previously added the gap-open and gap-extend coefficients of an affine fit. - Empty sequences raise a `ValueError` before any alignment work. - The default backend is nwgrad, and `num_threads` defaults to automatic. -- The intercept α is fitted exactly by a safeguarded Newton method (`alpha_solver="safeguarded_newton"`); the previous BFGS fit is available as `alpha_solver="bfgs"`. With labels of only one class, fitting α now raises a `ValueError`, since the likelihood then has no finite maximum. +- The intercept α is fitted exactly by a safeguarded Newton method (`alpha_solver="safeguarded_newton"`); the previous BFGS fit is available as `alpha_solver="bfgs"`. +- Labels are checked once, before any alignment work: labels other than 0 and 1, or labels of only one class, raise a `ValueError`, since with one class the likelihood has no finite maximum. This also applies with `initial_parameters`. +- The log-likelihood is computed from the logits, as Σ y·z − Σ log(1 + e^z) with z = α + score, without clipping probabilities. `loglik_trajectory` and `final_loglik` therefore differ from earlier versions on confident predictions, where the clipping capped each pair's loss at about 36. A non-finite score or α raises a `FloatingPointError`. +- `logit_logL` in `src.logit_link` takes `(alignment_scores, alpha, labels)` instead of `(logit_scores, labels)`; calls in the old form raise a `TypeError`. +- The results contain `alphabet`: the alphabet of the fit, whether given, taken from a warm start, or inferred from the sequences. ## Running tests