Skip to content

Radius filter for RetroRules v3 rule sets, plus a performance fix in the expansion loop - #37

Open
duartebred wants to merge 13 commits into
mainfrom
integrate-reaction-rules
Open

duartebred wants to merge 13 commits into
mainfrom
integrate-reaction-rules

Conversation

@duartebred

Copy link
Copy Markdown
Collaborator

This PR collects the work done to make BioCatalyzer usable with the RetroRules
v3.0.0 template collection, as part of my MSc dissertation. Four code commits,
each independent and reviewable on its own.

refactor: remove the --diameter parameter in favour of --radius

I added --diameter in 2025 for the 2019 RetroRules nomenclature. RetroRules
v3.0.0 annotates templates by radius (0–10) rather than diameter, so keeping both
names would expose two parameters for one concept.

The implementation also had two unresolved defects: loaders.py required a
Diameter column that the bundled rule file does not have, which made any run
with the default rules fail
; and matcher.py:211 still called
load_reaction_rules with the old signature. Removing it restores the tool's
original behaviour with its own rule set.

perf: index reaction rules by SMARTS instead of scanning the dataframe

_react_single resolved the reactants, the internal ID and the EC numbers with
three boolean masks over the rules dataframe, plus one over the compounds
dataframe — four full table scans per (compound, rule) pair. With 58 597 rules
these lookups take ~19.9 ms per pair against ~0.3 ms for the RDKit reaction, so
over 98% of the runtime was table traversal.

The mappings are now built once. SMARTS are unique within a rule set, so the
dictionary returns exactly what the mask returned. The index is rebuilt at all
six points where either dataframe is reassigned — the constructor and the
compounds, compounds_path, neutralize, reaction_rules and
organisms_path setters — since a stale index would resolve the wrong rule
silently.

Verified on 5 compounds × 3 556 rules: identical 76 products, 34.2 s → 5.5 s.

feat: optional radius filter, and --radius in both CLIs

Accepts an integer (6), a ;-separated list (4;6;8) or an inclusive range
(4:8). A rule is kept when any requested radius appears in its Radii field —
membership rather than equality, because one SMARTS can be generated at several
radii, so a template first seen at radius 1 and still valid at radius 6 belongs
to both sets. This is the same membership test the Organisms filter already
uses.

The filter is opt-in and column-driven: it defaults to ALL and is skipped, with
a warning, on rule sets without a Radii column, so the bundled files behave
exactly as before.

Tests

From 37 passed / 20 failed to 45 passed / 12 failed; coverage 58% → 82%. The
eight tests that now pass were the ones broken by the incomplete --diameter
work. The remaining twelve are all in tests/unit_tests/clis/ and fail because
the test harness builds commands by unquoted string concatenation, which breaks
on any absolute path containing spaces — unrelated to this PR.

Note on the older commits

The branch also carries commits from Feb/May 2025 and Aug 2026 that were never
merged: two requirements.txt fixes, the removal of a notebook that builds a
data file and is not a contribution to the tool, and the 2025 --diameter
snapshot that the first commit above reverts. Happy to rebase them out if you
prefer a narrower diff.

Known upstream issues found along the way, not addressed here

  • cli.py:115 calls logging.basicConfig with a path inside the output folder
    before BioReactor creates it, so a run fails if the folder does not exist.
  • react() materialises the full compound × rule Cartesian product as a list,
    and then a second list of the same pairs — 11.2M pairs for a 337 901-rule file.

Happy to open separate PRs for either.

duartebred and others added 10 commits February 19, 2025 10:47
…ring

- Introduce `diameter` parameter to control reaction rule environment size.
- Reorder constructor parameters for clarity.
- Update docstring to explain new parameter.
O notebook constroi um ficheiro de dados a partir do RetroRules 2019; nao
altera a ferramenta e por isso nao e contribuicao para o BioCatalyzer. Foi
movido para notebooks/legacy/ no projeto da dissertacao a 13/08/2026 e nao
deve entrar no Pull Request.
O --diameter foi escrito em 2025 para a nomenclatura do RetroRules 2019, onde os
templates eram identificados por diametro. O RetroRules v3.0.0 gera templates por
raio 0-10 e nao publica diametro, pelo que manter os dois nomes exporia dois
parametros para o mesmo conceito.

A implementacao tinha alem disso dois defeitos por corrigir: loaders.py exigia
uma coluna Diameter que o ficheiro de regras distribuido com a ferramenta nao
tem, fazendo falhar qualquer corrida com as regras default; e matcher.py:211
continuava a chamar load_reaction_rules com a assinatura antiga.

Os quatro ficheiros sao restaurados ao estado upstream. O trabalho de 2025 fica
preservado no branch snapshot-2025.
…the dataframe

_react_single resolved the reactants, the internal ID and the EC numbers of the
rule being applied with three boolean masks over the reaction rules dataframe,
plus one over the compounds dataframe, i.e. four full table scans for every
(compound, rule) pair. The cost grows linearly with the size of the rule set and
quickly dominates the chemistry itself: with 58 597 rules the lookups take
~19.9 ms per pair against ~0.3 ms for the RDKit reaction, so over 98% of the
wall-clock time is spent scanning.

The mappings are now built once, by _index_reaction_rules, and _react_single
reads them directly. SMARTS strings are unique within a BioCatalyzer rule set,
so the dictionary returns exactly what the mask returned and the output is
unchanged.

The index duplicates state that already lives in the dataframes, so it is
rebuilt at every point where either dataframe is reassigned: the constructor and
the compounds, compounds_path, neutralize, reaction_rules and organisms_path
setters. Missing any of these would leave the index stale and _react_single
would resolve the wrong rule, or raise a KeyError, without any warning.

Verified on 5 compounds x 3556 rules: identical 76 products, 34.2 s -> 5.5 s.
…lare Radii

RetroRules v3.0.0 publishes one template per radius (0-10) and states the radii a
template was modelled at in a Radii field. Radius controls how much environment a
SMARTS pattern encodes and therefore how promiscuous it is, so it is the natural
lever against the combinatorial explosion of a rule-based expansion.

load_reaction_rules gains a `radius` argument accepting an integer (6), a
;-separated list ('4;6;8') or an inclusive range ('4:8'). A rule is kept when any
of the requested radii appears in its Radii field: the same membership test the
Organisms filter already uses, so the two filters compose naturally. Membership
rather than equality is required because one SMARTS can be generated at several
radii, and a template first seen at radius 1 and still valid at radius 6 belongs
to both sets.

The filter is opt-in and column-driven. It defaults to 'ALL' and is skipped, with
a warning, on rule sets that carry no Radii column, so the bundled rule files and
any user-supplied file behave exactly as before.

BioReactor stores the setting and reapplies it wherever the rules are reloaded,
including the organisms_path setter, which would otherwise silently drop the
radius filter when the organism set changed.
Surfaces the reaction rule radius filter added to Loaders.load_reaction_rules.
Defaults to 'ALL', which reproduces the previous behaviour of both CLIs.
Nine tests over a fixture whose radius membership is known by construction:
rule i is modelled at every radius that is a multiple of i+1, so the expected
selection for each of the eleven radii is computed independently of the
implementation.

Covers each radius from 0 to 10 individually, the integer, list and range
spellings, composition with the organism filter, and _parse_radius.

Two tests document design decisions that are otherwise invisible:
- test_subsets_are_not_disjoint asserts that the radius subsets sum to more
  than the rule count, which is why membership rather than equality with a
  single modelled radius is the correct test.
- test_rule_set_without_radii_column_is_untouched asserts that rule files
  carrying no Radii column, including those distributed with the tool, are
  returned unfiltered with a warning.
Changes the default behaviour of the tool: rule sets that declare a Radii
column are now filtered to radius 6 unless the caller says otherwise.

Radius 6 was adopted as the operating point .

Callers opt out with radius='ALL', or select any other radius or range.

The warning emitted when a rule set declares no Radii column had to be
rewritten. It read 'a radius filter was requested', which was true only while
the default was 'ALL'; with a filtering default it would fire on every run
against the rule files shipped with the tool, complaining about a filter the
user never asked for. It now states the radius in effect, that no rules were
discarded, and how to silence it.

Tests: test_default_applies_no_filter replaced by test_default_is_radius_6,
which asserts that the implicit and explicit calls agree, plus a new
test_all_disables_the_filter covering the opt-out. Suite: 55 passed,
12 failed -- the same 12 pre-existing failures, caused by unquoted path
concatenation in the CLI test harness. Coverage 82% -> 84%.
The default reaction rule set distributed with the tool -- what is used when
no --reaction_rules is given -- is replaced with the converted RetroRules
v3.0.0 rule set: 337 901 rules, up from 22 949, still bzip2-compressed
(16.5 MB, comparable in size to the file it replaces).

Two columns present in the old file, RuleID and ReactionRuleSource, are absent
from the RetroRules-derived data. Checked against the codebase and the test
suite: neither is read anywhere. ReactionRuleSource is nonetheless populated
with 'RetroRules v3.0.0' on every row, to preserve the provenance information
the old file carried rather than drop it silently.

Three assertions in test_bioreactor.py compared the loaded rule table against
the exact shape of the old file (e.g. (7102, 7)) and failed once the file
changed size and gained columns (13 vs 7) -- expected, since the rule count is
a property of the data file, not of the code under test. Replaced with checks
for what the code actually depends on: that rules were loaded, and that the
SMARTS and Organisms columns exist. The one assertion on an output shape --
(3220, 7) -- had its row count relaxed for the same reason, while the column
count (7) is kept exact, since that one is fixed by the code and would be
worth catching if it changed.

Full suite: 55 passed, 12 failed -- the same 12 pre-existing failures caused
by unquoted path concatenation in the CLI test harness, unrelated to this
change.

Approved by J. Correia to replace the bundled file rather than only accept
the rule set via --reaction_rules.
@duartebred
duartebred force-pushed the integrate-reaction-rules branch from c72129a to 3655c70 Compare September 25, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant