Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
1bc356a
Merge pull request #6 from BioGeMT/devel
mciach May 26, 2026
67f0f4c
Merge pull request #7 from BioGeMT/devel
mciach Jun 9, 2026
686fba0
Merge pull request #8 from BioGeMT/devel
mciach Jun 9, 2026
2f69b66
Merge pull request #9 from BioGeMT/devel
mciach Jun 9, 2026
e5d11de
nwgrad
mciach Sep 28, 2026
ba1b089
Drop Python <3.13 upper bound; add tests/ package
michalsta Sep 29, 2026
a9af391
Add pytest config, test helpers and logit_link unit tests
michalsta Sep 29, 2026
a51a28c
Add unit tests for src.optimization
michalsta Sep 29, 2026
7b16624
Add alignment-helper tests and gradient-consistency integration tests
michalsta Sep 29, 2026
ffbdac8
Add integration tests for discrimalign()
michalsta Sep 29, 2026
761be7d
Add slow end-to-end learning tests and document running the suite
michalsta Sep 29, 2026
9ddaaa4
Fix gap open/extend counting when gaps alternate between sequences
michalsta Sep 29, 2026
771dd0f
Raise a clear ValueError when discrimalign() has no stepfunction
michalsta Sep 29, 2026
f2cc46f
Move the small mixed test fixture into tests/helpers.py
michalsta Sep 29, 2026
9b59f48
Add nwgrad dependency and parameter/gradient conversion layer
michalsta Sep 29, 2026
f66dbe2
Untrack committed bytecode and add .gitignore
michalsta Sep 30, 2026
5e499ff
Add GitHub Actions workflow running the test suite on Linux
michalsta Sep 30, 2026
0ec62aa
Bump setup-uv to v10 (Node 24)
michalsta Sep 30, 2026
44bdc7a
Pin setup-uv to v10.2.0; there is no floating v10 tag
michalsta Sep 30, 2026
94f183c
Route discrimalign() iterations through a backend engine and add the …
michalsta Sep 30, 2026
e6276ea
Test that the nwgrad backend follows the Biopython trajectory
michalsta Sep 30, 2026
33e3b5d
Cap gap scores at -1e-4 so local alignment stays well-posed in both b…
michalsta Sep 30, 2026
432dab1
CI: install nwgrad from a pinned GitHub commit
michalsta Sep 30, 2026
c6006c3
Use nwgrad's batch scores() and weighted_grad(); pin CI to nwgrad e2f…
michalsta Sep 30, 2026
61f4fa3
Fit one gap coefficient for linear gaps in the full-matrix initial es…
michalsta Oct 1, 2026
b97cb00
Split the initial estimate into features and fit; add get_initial_est…
michalsta Oct 1, 2026
9514243
Fit the nwgrad backend's initial estimate on nwgrad's own baseline al…
michalsta Oct 1, 2026
10a934c
Run the discrimalign() API tests on both backends
michalsta Oct 1, 2026
4859d8e
Check nwgrad's gradient against finite differences of nwgrad's own sc…
michalsta Oct 1, 2026
cab8584
Reject empty sequences before any alignment work
michalsta Oct 1, 2026
b8af1ed
Test the nwgrad backend on protein, long, lowercase and out-of-alphab…
michalsta Oct 1, 2026
1662151
num_threads=0 means all logical cores, passed to nwgrad explicitly
michalsta Oct 1, 2026
fb78497
CI: pin nwgrad to eeba40a (FMA-free weighted_grad)
michalsta Oct 1, 2026
98157c5
Default num_threads to 0: all logical cores with nwgrad, 1 with Biopy…
michalsta Oct 1, 2026
bf15300
Make nwgrad the default backend; remove the discrimalign_nwgrad and l…
michalsta Oct 1, 2026
48367bd
Run the slow learning tests on both backends
michalsta Oct 1, 2026
80e5cf3
Add a backend benchmark script
michalsta Oct 1, 2026
9aefcfc
README: backends, thread count, behavior changes, benchmarks; fix the…
michalsta Oct 1, 2026
bb0ec20
Add TODO-nwgrad.md listing the deferred follow-ups
michalsta Oct 1, 2026
09de7e2
Add miRNA inference interface
zacharopoulou Oct 1, 2026
4d32061
Merge pull request #11 from BioGeMT/feature/inference-interface
mciach Oct 1, 2026
0de90e5
Require nwgrad>=0.5.0; CI tests the released wheel
michalsta Oct 1, 2026
776bb5a
Build the initial-estimate feature matrix as one numpy array
michalsta Oct 1, 2026
84404fc
Read per-pair counts with SeqPairBatch.grads() where nwgrad has it
michalsta Oct 1, 2026
bdbae66
TODO-nwgrad: bulk per-pair gradients are on nwgrad main; require them…
michalsta Oct 1, 2026
5f78506
Merge upstream/main (PR #11: miRNA inference interface); missing step…
michalsta Oct 1, 2026
d9b214d
Fit alpha with a safeguarded Newton method by default; keep BFGS as a…
michalsta Oct 2, 2026
b807a4b
Iteration loop: check and convert labels once, reuse probabilities in…
michalsta Oct 2, 2026
e07ded8
Use nwgrad.logistic.step for the per-iteration logistic work on the n…
michalsta Oct 2, 2026
271f9cb
Test on an actual use case
mciach Oct 2, 2026
2fcc6c8
Merge branch 'nwgrad-backend' of github.com:michalsta/DiscrimAlign in…
mciach Oct 2, 2026
adfcea4
Skip Ciach's tests until he deciachs them.
michalsta Oct 2, 2026
b234856
Version bump nwgrad dep
michalsta Oct 2, 2026
8a96136
Add nwgrad_fill='rowwise' (nwgrad's row-wise DP fill, same fit, faste…
michalsta Oct 2, 2026
1cb4117
nwgrad_fill='interpair': nwgrad's inter-pair fill (several pairs per …
michalsta Oct 2, 2026
c5b9312
README: document nwgrad_fill
michalsta Oct 2, 2026
76f7ee8
Add reverse-complement option for inference
zacharopoulou Oct 2, 2026
7f2f1a8
Merge pull request #13 from BioGeMT/add-infer-reverse-complement-b
zacharopoulou Oct 2, 2026
29dad43
CI and README: take nwgrad from main (perf-dp is merged)
michalsta Oct 3, 2026
8854f13
deciached
mciach Oct 3, 2026
c9b1404
Return nwgrad's own alignment paths (via SeqPair.coordinates) instead…
michalsta Oct 3, 2026
9332b8b
Merge remote-tracking branch 'origin/perf' into nwgrad-backend
michalsta Oct 3, 2026
ec22814
Require nwgrad 0.5.2; drop the code paths for older nwgrad; CI back o…
michalsta Oct 3, 2026
6c7f44a
test_mirbench: import numpy, 2 iterations, mark slow and run only wit…
michalsta Oct 3, 2026
fa0da03
test_mirbench: back to 20 iterations
michalsta Oct 3, 2026
c3ec6d2
Merge remote-tracking branch 'upstream/main' into nwgrad-backend
michalsta Oct 3, 2026
0fc439a
Idk
mciach Oct 5, 2026
da11c3f
Calculate final alignments only when return_alignments==True
mciach Oct 5, 2026
849d924
Restore nwgrad's own final alignment paths, reverted by the da11c3f m…
michalsta Oct 5, 2026
6e8ed19
Trace the returned nwgrad alignments with the fill the fit used
michalsta Oct 5, 2026
c4abdf2
README: nwgrad's returned alignments are its own paths, not a Biopyth…
michalsta Oct 5, 2026
897541c
logit_logL from the logits: sum(y*z) - sum(logaddexp(0, z)), no clipp…
michalsta Oct 5, 2026
14e93ae
Check labels once at the start of discrimalign(); the loop fits alpha…
michalsta Oct 5, 2026
e4368e7
One logistic step for both engines: engine.logistic_step() in every i…
michalsta Oct 5, 2026
9699527
Resolve the alphabet in one helper and return it as results['alphabet']
michalsta Oct 5, 2026
2d8e425
README: behavior changes for the logL, label checks, logit_logL signa…
michalsta Oct 5, 2026
c7b1c3b
Merge pull request #12 from michalsta/nwgrad-backend
mciach Oct 7, 2026
bcbf944
Merge pull request #14 from BioGeMT/nwgrad
mciach Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: tests

on:
push:
pull_request:

jobs:
pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v10.2.0
with:
python-version: "3.12"
- run: uv sync
- run: uv pip show nwgrad
- run: uv run pytest
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.venv/
212 changes: 203 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
# 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
tests/ Unit and integration tests (pytest)
examples/mirna_pairs.csv Ready-to-run inference input example
case_study_for_mirna/ Bundled miRNA trained models and evaluation workflows
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
```

## Requirements

- Python `>=3.10,<3.13`
- Python `>=3.10`
- `uv` for environment management
- `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 `<experimental/simd>`, 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.
Expand Down Expand Up @@ -55,6 +60,94 @@ 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.

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:

```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
Expand All @@ -75,12 +168,13 @@ 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
from src.optimization import create_powerstep

seqlistA = ["AUGCUA", "CUGA"]
seqlistB = ["AUGGUA", "CUGU"]
Expand All @@ -93,16 +187,116 @@ result = discrimalign(
aligner_mode="local",
gap_mode="affine",
substitution_mode="symmetric",
num_threads=1,
stepfunction=create_powerstep(1e-5),
)

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:

```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
```

`stepfunction` maps the iteration number to a step size; it defaults to `create_powerstep(1e-4)`. `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.

`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

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:

- `"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"`, 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.

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 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"`.
- 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

`uv sync` installs `pytest` with the default `dev` dependency group. From the repository root:

```bash
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, 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
```

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.
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

Expand Down
Loading
Loading