From c0dc0a14bac301378469566afcfc622f7d6dde42 Mon Sep 17 00:00:00 2001 From: VinzentRisch Date: Tue, 12 May 2026 14:12:44 +0200 Subject: [PATCH 1/5] ENH: add core score action --- q2_feature_table/__init__.py | 3 +- q2_feature_table/_core.py | 39 ++++++++++++ q2_feature_table/plugin_setup.py | 43 ++++++++++++- q2_feature_table/tests/test_core.py | 93 +++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 q2_feature_table/_core.py create mode 100644 q2_feature_table/tests/test_core.py diff --git a/q2_feature_table/__init__.py b/q2_feature_table/__init__.py index 0a975de..b95fd57 100644 --- a/q2_feature_table/__init__.py +++ b/q2_feature_table/__init__.py @@ -20,6 +20,7 @@ from ._group import group from ._rename import rename_ids from ._heatmap import (heatmap, heatmap_choices) +from ._core import core_score try: from ._version import __version__ @@ -33,4 +34,4 @@ 'filter_seqs', 'subsample_ids', 'rename_ids', 'filter_features_conditionally', 'split', 'tabulate_feature_frequencies', 'tabulate_sample_frequencies', - 'summarize', 'normalize'] + 'summarize', 'normalize', 'core_score'] diff --git a/q2_feature_table/_core.py b/q2_feature_table/_core.py new file mode 100644 index 0000000..ca446d2 --- /dev/null +++ b/q2_feature_table/_core.py @@ -0,0 +1,39 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, QIIME 2 development team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file LICENSE, distributed with this software. +# ---------------------------------------------------------------------------- + +import numpy as np +import pandas as pd + + +def core_score( + table: pd.DataFrame, + min_rel_abundance: float = 1e-3, + mean_abundance_on_presence: bool = False, + eps: float = 1e-6 +) -> pd.DataFrame: + """Compute prevalence-abundance core scores for feature table columns.""" + prevalence = (table > min_rel_abundance).mean(axis=0) + if mean_abundance_on_presence: + mean_abundance = table.where( + table > min_rel_abundance).mean(axis=0).fillna(0) + else: + mean_abundance = table.mean(axis=0) + log_mean = np.log10(mean_abundance + eps) + + prevalence_scaled = _minmax_scale(prevalence, eps) + log_mean_scaled = _minmax_scale(log_mean, eps) + score = prevalence_scaled * log_mean_scaled + + result = pd.DataFrame({'core_score': score}) + result.index.name = 'id' + return result + + +def _minmax_scale(x, eps): + """Scale values to the range [0, 1] with an offset denominator.""" + return (x - x.min()) / (x.max() - x.min() + eps) diff --git a/q2_feature_table/plugin_setup.py b/q2_feature_table/plugin_setup.py index 0f205c4..5df0f30 100644 --- a/q2_feature_table/plugin_setup.py +++ b/q2_feature_table/plugin_setup.py @@ -14,7 +14,8 @@ from q2_types.feature_table import ( FeatureTable, Frequency, RelativeFrequency, PresenceAbsence, Composition) from q2_types.feature_data import ( - FeatureData, Sequence, Taxonomy, AlignedSequence, SequenceCharacteristics) + FeatureData, Sequence, Taxonomy, AlignedSequence, SequenceCharacteristics, + Importance) from q2_types.metadata import ImmutableMetadata import q2_feature_table @@ -776,3 +777,43 @@ "composition with common methods for RNA-seq.", citations=[citations["Zmrzlikar_RNAnorm_RNA-seq_data_2023"]], ) + +plugin.methods.register_function( + function=q2_feature_table.core_score, + inputs={'table': FeatureTable[RelativeFrequency]}, + parameters={'min_rel_abundance': Float % Range(0, 1, + inclusive_start=False), + 'mean_abundance_on_presence': Bool, + 'eps': Float % Range(0, None, inclusive_start=False)}, + outputs=[('core_scores', FeatureData[Importance])], + input_descriptions={ + 'table': 'The relative-frequency feature table to score.' + }, + parameter_descriptions={ + 'min_rel_abundance': ( + 'Minimum relative abundance used to count a feature as present ' + 'in a sample.' + ), + 'mean_abundance_on_presence': ( + 'If true, compute mean relative abundance using only samples ' + 'where the feature is greater than `min_rel_abundance`. If ' + 'false, compute mean relative abundance across all samples.' + ), + 'eps': ( + 'Small positive value used as the log offset and min-max ' + 'scaling denominator offset.' + ) + }, + output_descriptions={ + 'core_scores': 'Core scores for each feature.' + }, + name='Compute core score for features', + description=('Compute a prevalence-abundance core score for each feature ' + 'in a relative-frequency feature table. Prevalence is the ' + 'fraction of samples where a feature exceeds ' + '`min_rel_abundance`; mean relative abundance is computed ' + 'either across all samples or only samples above ' + '`min_rel_abundance`, then log-transformed with `eps`; both ' + 'quantities are min-max scaled across features and ' + 'multiplied to produce the final score.') +) diff --git a/q2_feature_table/tests/test_core.py b/q2_feature_table/tests/test_core.py new file mode 100644 index 0000000..28b2f71 --- /dev/null +++ b/q2_feature_table/tests/test_core.py @@ -0,0 +1,93 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2016-2026, QIIME 2 development team. +# +# Distributed under the terms of the Modified BSD License. +# +# The full license is in the file LICENSE, distributed with this software. +# ---------------------------------------------------------------------------- + +from unittest import main + +import numpy as np +import pandas as pd +import pandas.testing as pdt +from rachis.plugin.testing import TestPluginBase + +from q2_feature_table import core_score +from q2_feature_table._core import _minmax_scale + + +class TestCoreScore(TestPluginBase): + package = 'q2_feature_table.tests' + + def setUp(self): + super().setUp() + self.table = pd.DataFrame( + np.array([ + [0.0, 0.005, 0.0], + [0.02, 0.005, 0.0], + [0.04, 0.005, 0.3], + ]), + index=['S1', 'S2', 'S3'], + columns=['O1', 'O2', 'O3'] + ) + + def test_core_score(self): + observed = core_score(self.table) + + prevalence = np.array([2 / 3, 1, 1 / 3]) + mean_abundance = np.array([0.06 / 3, 0.005, 0.1]) + log_mean = np.log10(mean_abundance + 1e-6) + exp_prevalence = _minmax_scale(prevalence, 1e-6) + exp_log_mean = _minmax_scale(log_mean, 1e-6) + expected = pd.DataFrame( + {'core_score': exp_prevalence * exp_log_mean}, + index=pd.Index(['O1', 'O2', 'O3'], name='id') + ) + + pdt.assert_frame_equal(observed, expected) + + def test_core_score_values_are_finite(self): + observed = core_score(self.table) + self.assertTrue(np.isfinite(observed.to_numpy()).all()) + + def test_core_score_custom_parameters(self): + observed = core_score( + self.table, min_rel_abundance=0.01, eps=1e-3) + + prevalence = np.array([2 / 3, 0, 1 / 3]) + mean_abundance = np.array([0.06 / 3, 0.005, 0.1]) + log_mean = np.log10(mean_abundance + 1e-3) + exp_prevalence = _minmax_scale(prevalence, 1e-3) + exp_log_mean = _minmax_scale(log_mean, 1e-3) + expected = pd.DataFrame( + {'core_score': exp_prevalence * exp_log_mean}, + index=pd.Index(['O1', 'O2', 'O3'], name='id') + ) + + pdt.assert_frame_equal(observed, expected) + + def test_core_score_mean_abundance_on_presence(self): + observed = core_score( + self.table, min_rel_abundance=0.01, + mean_abundance_on_presence=True) + + prevalence = np.array([2 / 3, 0, 1 / 3]) + mean_abundance = np.array([0.03, 0, 0.3]) + log_mean = np.log10(mean_abundance + 1e-6) + exp_prevalence = _minmax_scale(prevalence, 1e-6) + exp_log_mean = _minmax_scale(log_mean, 1e-6) + expected = pd.DataFrame( + {'core_score': exp_prevalence * exp_log_mean}, + index=pd.Index(['O1', 'O2', 'O3'], name='id') + ) + + pdt.assert_frame_equal(observed, expected) + + def test_minmax_scale(self): + observed = _minmax_scale(pd.Series([2, 2, 2]), eps=1e-6) + self.assertTrue(np.isfinite(observed.to_numpy()).all()) + + +if __name__ == "__main__": + main() From 530af6e744e2370fc84b13b44b44eee64971fb9d Mon Sep 17 00:00:00 2001 From: VinzentRisch Date: Tue, 12 May 2026 14:16:35 +0200 Subject: [PATCH 2/5] lint --- q2_feature_table/plugin_setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/q2_feature_table/plugin_setup.py b/q2_feature_table/plugin_setup.py index 5df0f30..5fd4218 100644 --- a/q2_feature_table/plugin_setup.py +++ b/q2_feature_table/plugin_setup.py @@ -781,8 +781,8 @@ plugin.methods.register_function( function=q2_feature_table.core_score, inputs={'table': FeatureTable[RelativeFrequency]}, - parameters={'min_rel_abundance': Float % Range(0, 1, - inclusive_start=False), + parameters={'min_rel_abundance': + Float % Range(0, 1, inclusive_start=False), 'mean_abundance_on_presence': Bool, 'eps': Float % Range(0, None, inclusive_start=False)}, outputs=[('core_scores', FeatureData[Importance])], From 3cbd9f0973397d8f2be448adb0984928badfd04c Mon Sep 17 00:00:00 2001 From: VinzentRisch Date: Tue, 12 May 2026 14:18:04 +0200 Subject: [PATCH 3/5] lint --- q2_feature_table/plugin_setup.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/q2_feature_table/plugin_setup.py b/q2_feature_table/plugin_setup.py index 5fd4218..7c337eb 100644 --- a/q2_feature_table/plugin_setup.py +++ b/q2_feature_table/plugin_setup.py @@ -781,10 +781,11 @@ plugin.methods.register_function( function=q2_feature_table.core_score, inputs={'table': FeatureTable[RelativeFrequency]}, - parameters={'min_rel_abundance': - Float % Range(0, 1, inclusive_start=False), - 'mean_abundance_on_presence': Bool, - 'eps': Float % Range(0, None, inclusive_start=False)}, + parameters={ + 'min_rel_abundance': Float % Range(0, 1, inclusive_start=False), + 'mean_abundance_on_presence': Bool, + 'eps': Float % Range(0, None, inclusive_start=False) + }, outputs=[('core_scores', FeatureData[Importance])], input_descriptions={ 'table': 'The relative-frequency feature table to score.' From f126cc060356e45ee9773a9661b29cf588145485 Mon Sep 17 00:00:00 2001 From: VinzentRisch Date: Tue, 12 May 2026 14:24:51 +0200 Subject: [PATCH 4/5] MAINT: rename core score module --- q2_feature_table/__init__.py | 2 +- q2_feature_table/{_core.py => _core_score.py} | 0 q2_feature_table/tests/test_core.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename q2_feature_table/{_core.py => _core_score.py} (100%) diff --git a/q2_feature_table/__init__.py b/q2_feature_table/__init__.py index b95fd57..48663a5 100644 --- a/q2_feature_table/__init__.py +++ b/q2_feature_table/__init__.py @@ -20,7 +20,7 @@ from ._group import group from ._rename import rename_ids from ._heatmap import (heatmap, heatmap_choices) -from ._core import core_score +from ._core_score import core_score try: from ._version import __version__ diff --git a/q2_feature_table/_core.py b/q2_feature_table/_core_score.py similarity index 100% rename from q2_feature_table/_core.py rename to q2_feature_table/_core_score.py diff --git a/q2_feature_table/tests/test_core.py b/q2_feature_table/tests/test_core.py index 28b2f71..360b865 100644 --- a/q2_feature_table/tests/test_core.py +++ b/q2_feature_table/tests/test_core.py @@ -14,7 +14,7 @@ from rachis.plugin.testing import TestPluginBase from q2_feature_table import core_score -from q2_feature_table._core import _minmax_scale +from q2_feature_table._core_score import _minmax_scale class TestCoreScore(TestPluginBase): From 4dc9471113bf7ab2f559fa7c0b54b705c2c4d304 Mon Sep 17 00:00:00 2001 From: VinzentRisch Date: Tue, 12 May 2026 14:27:35 +0200 Subject: [PATCH 5/5] MAINT: rename core score tests --- q2_feature_table/tests/{test_core.py => test_core_score.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename q2_feature_table/tests/{test_core.py => test_core_score.py} (100%) diff --git a/q2_feature_table/tests/test_core.py b/q2_feature_table/tests/test_core_score.py similarity index 100% rename from q2_feature_table/tests/test_core.py rename to q2_feature_table/tests/test_core_score.py