From 3f85b0ee557428c4ea203f85965461a7334f895e Mon Sep 17 00:00:00 2001 From: Sunny Sun <38218185+sunnyosun@users.noreply.github.com> Date: Tue, 3 Dec 2024 14:51:57 +0100 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=8E=A8=20Update=20describe=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/core/_feature_manager.py | 47 +++++++++++++++----------------- lamindb/core/_label_manager.py | 21 +++++++------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/lamindb/core/_feature_manager.py b/lamindb/core/_feature_manager.py index 5ee47fe31..bfe382b9c 100644 --- a/lamindb/core/_feature_manager.py +++ b/lamindb/core/_feature_manager.py @@ -282,13 +282,15 @@ def _get_featuresets_postgres( return fs_data -def _create_feature_table(name: str, registry_str: str, data: list) -> Table: +def _create_feature_table( + name: str, registry_str: str, data: list, show_header: bool = False +) -> Table: """Create a Rich table for a feature group.""" table = Table( Column(name, style="", no_wrap=True, width=NAME_WIDTH), Column(registry_str, style="dim", no_wrap=True, width=TYPE_WIDTH), Column("", width=VALUES_WIDTH, no_wrap=True), - show_header=True, + show_header=show_header, box=None, pad_edge=False, ) @@ -407,14 +409,14 @@ def describe_features( if to_dict: return dictionary - # Dataset section + # Internal features section internal_features_slot: dict[ str, list ] = {} # internal features from the `Feature` registry that contain labels for feature_name, feature_row in internal_feature_labels.items(): slot, _ = feature_data.get(feature_name) internal_features_slot.setdefault(slot, []).append(feature_row) - dataset_tree_children = [] + int_features_tree_children = [] for slot, (feature_set, feature_names) in feature_set_data.items(): if slot in internal_features_slot: @@ -425,7 +427,7 @@ def describe_features( for feature_name in feature_names if feature_name ] - dataset_tree_children.append( + int_features_tree_children.append( _create_feature_table( Text.assemble( (slot, "violet"), @@ -434,46 +436,41 @@ def describe_features( ), Text.assemble((f"[{feature_set.registry}]", "pink1")), feature_rows, + show_header=True, ) ) ## internal features from the non-`Feature` registry - if dataset_tree_children: + if int_features_tree_children: dataset_tree = tree.add( Text.assemble( - ("Dataset", "bold bright_magenta"), + ("Internal features", "bold bright_magenta"), ("/", "dim"), (".feature_sets", "dim bold"), ) ) - for child in dataset_tree_children: + for child in int_features_tree_children: dataset_tree.add(child) - # Annotations section - ## external features - features_tree_children = [] + # External features + ext_features_tree_children = [] if external_data: - features_tree_children.append( + ext_features_tree_children.append( _create_feature_table( - Text.assemble( - ("Params" if print_params else "Features", "green_yellow") - ), + "", "", external_data, ) ) - annotations_tree = None - if features_tree_children: - annotations_tree = tree.add(Text("Annotations", style="bold dark_orange")) - for child in features_tree_children: - annotations_tree.add(child) + # ext_features_tree = None + ext_features_header = Text("External features", style="bold dark_orange") + if ext_features_tree_children: + ext_features_tree = tree.add(ext_features_header) + for child in ext_features_tree_children: + ext_features_tree.add(child) if with_labels: labels_tree = describe_labels(self, as_subtree=True) if labels_tree: - if annotations_tree is None: - annotations_tree = tree.add( - Text("Annotations", style="bold dark_orange") - ) - annotations_tree.add(labels_tree) + tree.add(labels_tree) return tree diff --git a/lamindb/core/_label_manager.py b/lamindb/core/_label_manager.py index 23453c3f7..dd17d9675 100644 --- a/lamindb/core/_label_manager.py +++ b/lamindb/core/_label_manager.py @@ -5,10 +5,11 @@ from typing import TYPE_CHECKING from django.db import connections -from lamin_utils import colors, logger +from lamin_utils import logger from lnschema_core.models import CanCurate, Feature from rich.table import Column, Table from rich.text import Text +from rich.tree import Tree from lamindb._from_values import _print_values from lamindb._record import ( @@ -32,7 +33,6 @@ if TYPE_CHECKING: from lnschema_core.models import Artifact, Collection, Record - from rich.tree import Tree from lamindb._query_set import QuerySet @@ -99,15 +99,10 @@ def describe_labels( return tree labels_table = Table( - Column( - Text.assemble(("Labels", "green_yellow")), - style="", - no_wrap=True, - width=NAME_WIDTH, - ), + Column("", style="", no_wrap=True, width=NAME_WIDTH), Column("", style="dim", no_wrap=True, width=TYPE_WIDTH), Column("", width=VALUES_WIDTH, no_wrap=True), - # show_header=True, + show_header=False, box=None, pad_edge=False, ) @@ -126,12 +121,16 @@ def describe_labels( f".{related_name}", Text(type_str, style="dim"), print_values ) + labels_header = Text("Labels", style="bold green_yellow") if as_subtree: if labels_table.rows: - return labels_table + labels_tree = Tree(labels_header, guide_style="dim") + labels_tree.add(labels_table) + return labels_tree else: if labels_table.rows: - tree.add(labels_table) + labels_tree = tree.add(labels_header) + labels_tree.add(labels_table) return tree From 8495a81c0cdaccb8b1b1ee36ee111c8c73964185 Mon Sep 17 00:00:00 2001 From: Sunny Sun <38218185+sunnyosun@users.noreply.github.com> Date: Tue, 3 Dec 2024 15:05:30 +0100 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=92=9A=20Fix=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/core/test_describe_df.py | 63 +++++++++++++----------- tests/core/test_feature_label_manager.py | 5 +- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/tests/core/test_describe_df.py b/tests/core/test_describe_df.py index 3d5e264df..e6d73ca6d 100644 --- a/tests/core/test_describe_df.py +++ b/tests/core/test_describe_df.py @@ -145,44 +145,52 @@ def test_curate_df(): # general section assert len(description_tree.children) == 3 - gernal_node = description_tree.children[0] - assert gernal_node.label.plain == "General" - assert gernal_node.children[0].label == f".uid = '{artifact.uid}'" - assert gernal_node.children[1].label == ".key = 'example_datasets/dataset1.h5ad'" - assert ".size = " in gernal_node.children[2].label - assert ".hash = " in gernal_node.children[3].label - assert gernal_node.children[4].label.plain == ".n_observations = 3" - assert ".path = " in gernal_node.children[5].label.plain - assert ".created_by = " in gernal_node.children[6].label.plain - assert ".created_at = " in gernal_node.children[7].label.plain + general_node = description_tree.children[0] + assert general_node.label.plain == "General" + assert general_node.children[0].label == f".uid = '{artifact.uid}'" + assert general_node.children[1].label == ".key = 'example_datasets/dataset1.h5ad'" + assert ".size = " in general_node.children[2].label + assert ".hash = " in general_node.children[3].label + assert general_node.children[4].label.plain == ".n_observations = 3" + assert ".path = " in general_node.children[5].label.plain + assert ".created_by = " in general_node.children[6].label.plain + assert ".created_at = " in general_node.children[7].label.plain # dataset section - dataset_node = description_tree.children[1] - assert dataset_node.label.plain == "Dataset/.feature_sets" - assert len(dataset_node.children) == 2 - assert len(dataset_node.children[0].label.rows) == 3 - assert len(dataset_node.children[0].label.columns) == 3 - assert dataset_node.children[0].label.columns[0].header.plain == "var • 3" - assert dataset_node.children[0].label.columns[0]._cells == ["CD8A", "CD4", "CD14"] - assert dataset_node.children[0].label.columns[1].header.plain == "[bionty.Gene]" - assert dataset_node.children[0].label.columns[1]._cells[0].plain == "int" - assert dataset_node.children[1].label.columns[0].header.plain == "obs • 4" - assert dataset_node.children[1].label.columns[0]._cells == [ + int_features_node = description_tree.children[1] + assert int_features_node.label.plain == "Internal features/.feature_sets" + assert len(int_features_node.children) == 2 + assert len(int_features_node.children[0].label.rows) == 3 + assert len(int_features_node.children[0].label.columns) == 3 + assert int_features_node.children[0].label.columns[0].header.plain == "var • 3" + assert int_features_node.children[0].label.columns[0]._cells == [ + "CD8A", + "CD4", + "CD14", + ] + assert ( + int_features_node.children[0].label.columns[1].header.plain == "[bionty.Gene]" + ) + assert int_features_node.children[0].label.columns[1]._cells[0].plain == "int" + assert int_features_node.children[1].label.columns[0].header.plain == "obs • 4" + assert int_features_node.children[1].label.columns[0]._cells == [ "cell_medium", "cell_type_by_expert", "cell_type_by_model", ] - assert dataset_node.children[1].label.columns[1].header.plain == "[Feature]" - assert dataset_node.children[1].label.columns[1]._cells[0].plain == "cat[ULabel]" + assert int_features_node.children[1].label.columns[1].header.plain == "[Feature]" + assert ( + int_features_node.children[1].label.columns[1]._cells[0].plain == "cat[ULabel]" + ) assert ( - dataset_node.children[1].label.columns[1]._cells[1].plain + int_features_node.children[1].label.columns[1]._cells[1].plain == "cat[bionty.CellType]" ) assert ( - dataset_node.children[1].label.columns[1]._cells[2].plain + int_features_node.children[1].label.columns[1]._cells[2].plain == "cat[bionty.CellType]" ) - assert dataset_node.children[1].label.columns[2]._cells == [ + assert int_features_node.children[1].label.columns[2]._cells == [ "DMSO, IFNG", "B cell, T cell", "B cell, T cell", @@ -190,11 +198,10 @@ def test_curate_df(): # annotations section annotations_node = description_tree.children[2] - assert annotations_node.label.plain == "Annotations" + assert annotations_node.label.plain == "External features" assert len(annotations_node.children) == 2 assert len(annotations_node.children[0].label.columns) == 3 assert len(annotations_node.children[0].label.rows) == 4 - assert annotations_node.children[0].label.columns[0].header.plain == "Features" assert annotations_node.children[0].label.columns[0]._cells == [ "study", "date_of_study", diff --git a/tests/core/test_feature_label_manager.py b/tests/core/test_feature_label_manager.py index ce560e9e8..55c2b5b2f 100644 --- a/tests/core/test_feature_label_manager.py +++ b/tests/core/test_feature_label_manager.py @@ -219,10 +219,9 @@ def test_features_add_remove(adata): tree = describe_features(artifact) print_rich_tree(tree) assert tree.label.plain == "Artifact .h5ad/AnnData" - assert tree.children[0].label.plain == "Annotations" + assert tree.children[0].label.plain == "External features" assert len(tree.children[0].children[0].label.columns) == 3 assert len(tree.children[0].children[0].label.rows) == 10 - assert tree.children[0].children[0].label.columns[0].header.plain == "Features" assert tree.children[0].children[0].label.columns[0]._cells == [ "cell_type_by_expert", "disease", @@ -351,7 +350,7 @@ def test_params_add(): # test describe params tree = describe_features(artifact, print_params=True) assert tree.label.plain == "Artifact .pt" - assert tree.children[0].label.plain == "Annotations" + assert tree.children[0].label.plain == "External features" assert len(tree.children[0].children[0].label.columns) == 3 assert tree.children[0].children[0].label.columns[0].header.plain == "Params" assert tree.children[0].children[0].label.columns[0]._cells == [ From 22de870b79ea365cc47ccb0300e3809a452f7e18 Mon Sep 17 00:00:00 2001 From: Sunny Sun <38218185+sunnyosun@users.noreply.github.com> Date: Tue, 3 Dec 2024 16:09:44 +0100 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=92=9A=20Fix=20all=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/core/_feature_manager.py | 4 +- tests/core/test_describe_df.py | 48 ++++++++++++------------ tests/core/test_feature_label_manager.py | 3 +- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/lamindb/core/_feature_manager.py b/lamindb/core/_feature_manager.py index bfe382b9c..b5b485632 100644 --- a/lamindb/core/_feature_manager.py +++ b/lamindb/core/_feature_manager.py @@ -462,7 +462,9 @@ def describe_features( ) ) # ext_features_tree = None - ext_features_header = Text("External features", style="bold dark_orange") + ext_features_header = Text( + "Params" if print_params else "External features", style="bold dark_orange" + ) if ext_features_tree_children: ext_features_tree = tree.add(ext_features_header) for child in ext_features_tree_children: diff --git a/tests/core/test_describe_df.py b/tests/core/test_describe_df.py index e6d73ca6d..d334a197b 100644 --- a/tests/core/test_describe_df.py +++ b/tests/core/test_describe_df.py @@ -144,7 +144,9 @@ def test_curate_df(): description_tree = _describe_postgres(artifact, print_types=True) # general section - assert len(description_tree.children) == 3 + assert ( + len(description_tree.children) == 4 + ) # general, internal features, external features, labels general_node = description_tree.children[0] assert general_node.label.plain == "General" assert general_node.children[0].label == f".uid = '{artifact.uid}'" @@ -196,43 +198,43 @@ def test_curate_df(): "B cell, T cell", ] - # annotations section - annotations_node = description_tree.children[2] - assert annotations_node.label.plain == "External features" - assert len(annotations_node.children) == 2 - assert len(annotations_node.children[0].label.columns) == 3 - assert len(annotations_node.children[0].label.rows) == 4 - assert annotations_node.children[0].label.columns[0]._cells == [ + # external features section + ext_features_node = description_tree.children[2] + assert ext_features_node.label.plain == "External features" + assert len(ext_features_node.children) == 1 + assert len(ext_features_node.children[0].label.columns) == 3 + assert len(ext_features_node.children[0].label.rows) == 4 + assert ext_features_node.children[0].label.columns[0]._cells == [ "study", "date_of_study", "study_note", "temperature", ] assert ( - annotations_node.children[0].label.columns[1]._cells[0].plain == "cat[ULabel]" + ext_features_node.children[0].label.columns[1]._cells[0].plain == "cat[ULabel]" ) - assert annotations_node.children[0].label.columns[1]._cells[1].plain == "date" - assert annotations_node.children[0].label.columns[1]._cells[2].plain == "str" - assert annotations_node.children[0].label.columns[1]._cells[3].plain == "float" - assert annotations_node.children[0].label.columns[2]._cells == [ + assert ext_features_node.children[0].label.columns[1]._cells[1].plain == "date" + assert ext_features_node.children[0].label.columns[1]._cells[2].plain == "str" + assert ext_features_node.children[0].label.columns[1]._cells[3].plain == "float" + assert ext_features_node.children[0].label.columns[2]._cells == [ "Candidate marker study 1", "2024-12-01", "We had a great time performing this study and the results look compelling.", "21.6", ] - assert len(annotations_node.children[0].label.columns) == 3 - assert len(annotations_node.children[1].label.rows) == 2 - assert annotations_node.children[1].label.columns[0].header.plain == "Labels" - assert annotations_node.children[1].label.columns[0]._cells == [ + + # labels section + labels_node = description_tree.children[3].label + assert labels_node.label.plain == "Labels" + assert len(labels_node.children[0].label.columns) == 3 + assert len(labels_node.children[0].label.rows) == 2 + assert labels_node.children[0].label.columns[0]._cells == [ ".cell_types", ".ulabels", ] - assert ( - annotations_node.children[1].label.columns[1]._cells[0].plain - == "bionty.CellType" - ) - assert annotations_node.children[1].label.columns[1]._cells[1].plain == "ULabel" - assert annotations_node.children[1].label.columns[2]._cells == [ + assert labels_node.children[0].label.columns[1]._cells[0].plain == "bionty.CellType" + assert labels_node.children[0].label.columns[1]._cells[1].plain == "ULabel" + assert labels_node.children[0].label.columns[2]._cells == [ "'B cell', 'T cell'", "'DMSO', 'IFNG', 'Candidate marker study 1'", ] diff --git a/tests/core/test_feature_label_manager.py b/tests/core/test_feature_label_manager.py index 55c2b5b2f..62c85db42 100644 --- a/tests/core/test_feature_label_manager.py +++ b/tests/core/test_feature_label_manager.py @@ -350,9 +350,8 @@ def test_params_add(): # test describe params tree = describe_features(artifact, print_params=True) assert tree.label.plain == "Artifact .pt" - assert tree.children[0].label.plain == "External features" + assert tree.children[0].label.plain == "Params" assert len(tree.children[0].children[0].label.columns) == 3 - assert tree.children[0].children[0].label.columns[0].header.plain == "Params" assert tree.children[0].children[0].label.columns[0]._cells == [ "learning_rate", "quantification", From 763cac9cd64564b2c338fcd33caf3d8506c1ba36 Mon Sep 17 00:00:00 2001 From: Sunny Sun <38218185+sunnyosun@users.noreply.github.com> Date: Tue, 3 Dec 2024 20:21:31 +0100 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=8E=A8=20Fix=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/_curate.py | 21 +++++++++++-------- lamindb/core/_feature_manager.py | 36 ++++++++++++++++++++------------ 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/lamindb/_curate.py b/lamindb/_curate.py index 6b837a40e..a4f0575e0 100644 --- a/lamindb/_curate.py +++ b/lamindb/_curate.py @@ -1449,7 +1449,9 @@ def standardize(self, key: str): df = table.to_pandas() # map values - df[slot_key] = df[slot_key].map(lambda val: syn_mapper.get(val, val)) # noqa: B023 + df[slot_key] = df[slot_key].map( + lambda val: syn_mapper.get(val, val) # noqa + ) # write the mapped values with _open_tiledbsoma(self._experiment_uri, mode="w") as experiment: slot(experiment).write(pa.Table.from_pandas(df, schema=table.schema)) @@ -2044,16 +2046,17 @@ def _add_labels( ) if len(labels) == 0: continue + label_ref_is_name = None if hasattr(registry, "_name_field"): label_ref_is_name = field.field.name == registry._name_field - add_labels( - artifact, - records=labels, - feature=feature, - feature_ref_is_name=feature_ref_is_name, - label_ref_is_name=label_ref_is_name, - from_curator=True, - ) + add_labels( + artifact, + records=labels, + feature=feature, + feature_ref_is_name=feature_ref_is_name, + label_ref_is_name=label_ref_is_name, + from_curator=True, + ) if artifact._accessor == "MuData": for modality, modality_fields in fields.items(): diff --git a/lamindb/core/_feature_manager.py b/lamindb/core/_feature_manager.py index b5b485632..84ecdecc2 100644 --- a/lamindb/core/_feature_manager.py +++ b/lamindb/core/_feature_manager.py @@ -347,15 +347,15 @@ def describe_features( for feature_name in feature_names: feature_data[feature_name] = (slot, feature_set.registry) - internal_feature_names: set[str] = {} # type: ignore + internal_feature_names: dict[str, str] = {} if isinstance(self, Artifact): feature_sets = self.feature_sets.filter(registry="Feature").all() - internal_feature_names = set() # type: ignore + internal_feature_names = {} if len(feature_sets) > 0: for feature_set in feature_sets: - internal_feature_names = internal_feature_names.union( - set(feature_set.members.values_list("name", flat=True)) - ) # type: ignore + internal_feature_names.update( + dict(feature_set.members.values_list("name", "dtype")) + ) # categorical feature values # Get the categorical data using the appropriate method @@ -410,18 +410,29 @@ def describe_features( return dictionary # Internal features section - internal_features_slot: dict[ - str, list - ] = {} # internal features from the `Feature` registry that contain labels + # internal features that contain labels (only `Feature` features contain labels) + internal_feature_labels_slot: dict[str, list] = {} for feature_name, feature_row in internal_feature_labels.items(): slot, _ = feature_data.get(feature_name) - internal_features_slot.setdefault(slot, []).append(feature_row) - int_features_tree_children = [] + internal_feature_labels_slot.setdefault(slot, []).append(feature_row) + int_features_tree_children = [] for slot, (feature_set, feature_names) in feature_set_data.items(): - if slot in internal_features_slot: - feature_rows = internal_features_slot[slot] + if slot in internal_feature_labels_slot: + # add internal Feature features with labels + feature_rows = internal_feature_labels_slot[slot] + # add internal Feature features without labels + feature_rows += [ + ( + feature_name, + Text(str(internal_feature_names.get(feature_name)), style="dim"), + "", + ) + for feature_name in feature_names + if feature_name and feature_name not in internal_feature_labels + ] else: + # add internal non-Feature features without labels feature_rows = [ (feature_name, Text(str(feature_set.dtype), style="dim"), "") for feature_name in feature_names @@ -439,7 +450,6 @@ def describe_features( show_header=True, ) ) - ## internal features from the non-`Feature` registry if int_features_tree_children: dataset_tree = tree.add( Text.assemble( From 303fc1dd0d3915ed3057e6e581e1b54a915555fd Mon Sep 17 00:00:00 2001 From: Sunny Sun <38218185+sunnyosun@users.noreply.github.com> Date: Wed, 4 Dec 2024 13:46:40 +0100 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=8E=A8=20Cover=20more=20edge=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lamindb/core/_feature_manager.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lamindb/core/_feature_manager.py b/lamindb/core/_feature_manager.py index fa66ebe95..541dc8509 100644 --- a/lamindb/core/_feature_manager.py +++ b/lamindb/core/_feature_manager.py @@ -434,7 +434,18 @@ def describe_features( else: # add internal non-Feature features without labels feature_rows = [ - (feature_name, Text(str(feature_set.dtype), style="dim"), "") + ( + feature_name, + Text( + str( + internal_feature_names.get(feature_name) + if feature_name in internal_feature_names + else feature_set.dtype + ), + style="dim", + ), + "", + ) for feature_name in feature_names if feature_name ] From 5471e04068f9bdeff46cf0a710a4119449006051 Mon Sep 17 00:00:00 2001 From: Sunny Sun <38218185+sunnyosun@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:08:21 +0100 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=92=9A=20Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/core/test_describe_df.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/core/test_describe_df.py b/tests/core/test_describe_df.py index d334a197b..a579480ef 100644 --- a/tests/core/test_describe_df.py +++ b/tests/core/test_describe_df.py @@ -179,6 +179,7 @@ def test_curate_df(): "cell_medium", "cell_type_by_expert", "cell_type_by_model", + "sample_note", ] assert int_features_node.children[1].label.columns[1].header.plain == "[Feature]" assert ( From a205e635c5cde5ddf11b17eb3edf25fd507cfac2 Mon Sep 17 00:00:00 2001 From: Sunny Sun <38218185+sunnyosun@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:54:31 +0100 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=92=9A=20Fix=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/core/test_describe_df.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/core/test_describe_df.py b/tests/core/test_describe_df.py index a579480ef..39f7f069c 100644 --- a/tests/core/test_describe_df.py +++ b/tests/core/test_describe_df.py @@ -197,6 +197,7 @@ def test_curate_df(): "DMSO, IFNG", "B cell, T cell", "B cell, T cell", + "", ] # external features section