tfmindi 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tfmindi/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """Package initialization for tfmindi."""
2
+
3
+ import warnings
4
+ from importlib.metadata import version
5
+
6
+ # Suppress numba hashing warning from tangermeme
7
+ warnings.filterwarnings("ignore", message=".*FNV hashing is not implemented in Numba.*", category=UserWarning)
8
+
9
+ # Pre-import rapids_singlecell to establish proper CUDA environment
10
+ # This must happen BEFORE any other CUDA libraries are imported
11
+ try:
12
+ import rapids_singlecell # type: ignore
13
+ except ImportError:
14
+ pass
15
+
16
+ from tfmindi import pl, pp, tl # noqa: E402
17
+ from tfmindi.backends import get_backend, is_gpu_available, set_backend # noqa: E402
18
+ from tfmindi.datasets import ( # noqa: E402
19
+ fetch_motif_annotations,
20
+ fetch_motif_collection,
21
+ load_motif_annotations,
22
+ load_motif_collection,
23
+ load_motif_to_dbd,
24
+ )
25
+ from tfmindi.io import load_h5ad, save_h5ad # noqa: E402
26
+ from tfmindi.types import Pattern, Seqlet # noqa: E402
27
+
28
+ __all__ = [
29
+ "pl",
30
+ "pp",
31
+ "tl",
32
+ "Pattern",
33
+ "Seqlet",
34
+ "get_backend",
35
+ "set_backend",
36
+ "is_gpu_available",
37
+ "fetch_motif_collection",
38
+ "fetch_motif_annotations",
39
+ "load_motif_collection",
40
+ "load_motif_annotations",
41
+ "load_motif_to_dbd",
42
+ "save_h5ad",
43
+ "load_h5ad",
44
+ ]
45
+
46
+ __version__ = version("tfmindi")
@@ -0,0 +1,151 @@
1
+ """Backend detection and configuration for CPU/GPU acceleration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import warnings
7
+ from typing import Any, Literal
8
+
9
+ Backend = Literal["cpu", "gpu"]
10
+
11
+ # Global backend state
12
+ _backend: Backend | None = None
13
+ _gpu_available: bool | None = None
14
+
15
+
16
+ def _check_gpu_availability() -> bool:
17
+ """Check if GPU acceleration packages are available."""
18
+ global _gpu_available
19
+ if _gpu_available is not None:
20
+ return _gpu_available
21
+
22
+ try:
23
+ import cupy as cp # type: ignore
24
+
25
+ device_count = cp.cuda.runtime.getDeviceCount()
26
+ _gpu_available = device_count > 0
27
+ return _gpu_available # type: ignore
28
+ except ImportError:
29
+ _gpu_available = False
30
+ return False
31
+
32
+
33
+ def get_backend() -> str:
34
+ """
35
+ Get the current computational backend.
36
+
37
+ Returns the backend based on the following priority:
38
+ 1. Explicitly set backend via set_backend()
39
+ 2. Environment variable TFMINDI_BACKEND
40
+ 3. Automatic detection based on GPU availability
41
+
42
+ Returns
43
+ -------
44
+ Backend type: "cpu" or "gpu"
45
+ """
46
+ global _backend
47
+
48
+ if _backend is not None:
49
+ return _backend
50
+
51
+ # Check environment variable
52
+ env_backend = os.getenv("TFMINDI_BACKEND", "").lower()
53
+ if env_backend in ["cpu", "gpu"]:
54
+ _backend = env_backend # type: ignore
55
+ if _backend == "gpu" and not _check_gpu_availability():
56
+ warnings.warn(
57
+ "GPU backend requested but GPU packages not available. "
58
+ "Install with 'pip install tfmindi[gpu]'. Falling back to CPU.",
59
+ UserWarning,
60
+ stacklevel=2,
61
+ )
62
+ _backend = "cpu"
63
+ return _backend # type: ignore
64
+
65
+ # Auto-detect based on availability
66
+ _backend = "gpu" if _check_gpu_availability() else "cpu"
67
+ return _backend
68
+
69
+
70
+ def set_backend(backend: Backend) -> None:
71
+ """
72
+ Explicitly set the computational backend.
73
+
74
+ Parameters
75
+ ----------
76
+ backend
77
+ Backend type: "cpu" or "gpu"
78
+
79
+ Raises
80
+ ------
81
+ ValueError
82
+ If backend is not supported
83
+ ImportError
84
+ If GPU backend is requested but packages are not available
85
+ """
86
+ global _backend
87
+
88
+ if backend not in ["cpu", "gpu"]:
89
+ raise ValueError(f"Invalid backend: {backend}. Must be 'cpu' or 'gpu'.")
90
+
91
+ if backend == "gpu" and not _check_gpu_availability():
92
+ raise ImportError(
93
+ "GPU backend requested but required packages not available. Install with 'pip install tfmindi[gpu]'."
94
+ )
95
+
96
+ _backend = backend
97
+
98
+
99
+ def is_gpu_available() -> bool:
100
+ """Check if GPU acceleration is available."""
101
+ return _check_gpu_availability()
102
+
103
+
104
+ def get_array_module() -> Any:
105
+ """Get the appropriate array module (numpy or cupy) based on backend."""
106
+ if get_backend() == "gpu":
107
+ try:
108
+ import cupy as cp
109
+
110
+ return cp
111
+ except ImportError:
112
+ warnings.warn("CuPy not available, falling back to NumPy", UserWarning, stacklevel=2)
113
+ import numpy as np
114
+
115
+ return np
116
+ else:
117
+ import numpy as np
118
+
119
+ return np
120
+
121
+
122
+ def to_cpu(array: Any) -> Any:
123
+ """Transfer array to CPU memory if it's on GPU."""
124
+ if hasattr(array, "get"): # CuPy array
125
+ return array.get()
126
+ return array
127
+
128
+
129
+ def to_gpu(array: Any) -> Any:
130
+ """Transfer array to GPU memory if GPU backend is active."""
131
+ if get_backend() == "gpu":
132
+ try:
133
+ import cupy as cp
134
+
135
+ if hasattr(array, "get"): # Already on GPU
136
+ return array
137
+ return cp.asarray(array)
138
+ except ImportError:
139
+ warnings.warn("CuPy not available, keeping array on CPU", UserWarning, stacklevel=2)
140
+ return array
141
+
142
+
143
+ __all__ = [
144
+ "Backend",
145
+ "get_backend",
146
+ "set_backend",
147
+ "is_gpu_available",
148
+ "get_array_module",
149
+ "to_cpu",
150
+ "to_gpu",
151
+ ]
tfmindi/datasets.py ADDED
@@ -0,0 +1,416 @@
1
+ """Dataset functions for TF-MInDi: fetching and loading motif collections and annotations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import zipfile
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ import pooch
12
+
13
+ _motif_index = None
14
+
15
+
16
+ def _get_motif_index():
17
+ """
18
+ Set up the pooch motif collection registry from pycistarget.
19
+
20
+ Returns
21
+ -------
22
+ pooch.Pooch
23
+ The motif collection registry.
24
+ """
25
+ global _motif_index
26
+
27
+ if _motif_index is None:
28
+ _motif_index = pooch.create(
29
+ path=pooch.os_cache("tfmindi"),
30
+ base_url="https://resources.aertslab.org/cistarget/",
31
+ env="TFMINDI_DATA_DIR",
32
+ registry={
33
+ # Motif collections
34
+ "motif_collections/v10nr_clust_public/v10nr_clust_public.zip": "sha256:70dab42794f42471a3c22f5efe78ec2c8af96127656607cb0a929b4adffc2b97",
35
+ # Motif annotations (motifs-v{version}-nr.{species}-m0.001-o0.0.tbl)
36
+ # v8 (only Drosophila)
37
+ "motif2tf/motifs-v8-nr.flybase-m0.001-o0.0.tbl": "sha256:ffc550325334507c8ab2f6f79170fd0dfbcbecb33aee96040bf1f386fb3f982d",
38
+ # v9 annotations
39
+ "motif2tf/motifs-v9-nr.flybase-m0.001-o0.0.tbl": "sha256:db3458fdb38616758ea54543a42c7fc0e0ebb292cbf928d2648aefbf2cd59314",
40
+ "motif2tf/motifs-v9-nr.hgnc-m0.001-o0.0.tbl": "sha256:9e085e8e6ecd6f73a47fe435ed50d583b92bf7814d1bf38ae358e73c6207da2b",
41
+ "motif2tf/motifs-v9-nr.mgi-m0.001-o0.0.tbl": "sha256:cfab1245dbe770b1073f8048b1316285c9ce4b1a55d8ebca78962a2f406a172d",
42
+ # v10nr_clust annotations
43
+ "motif2tf/motifs-v10nr_clust-nr.chicken-m0.001-o0.0.tbl": "sha256:1ede59e4a737d822b7d6713243b83bf8f618f978c4fae1810fab265a30dfe3ba",
44
+ "motif2tf/motifs-v10nr_clust-nr.flybase-m0.001-o0.0.tbl": "sha256:91284e94b0317b764dc2f8d8147d30db707605df757ff7de16fb0953c63fda2a",
45
+ "motif2tf/motifs-v10nr_clust-nr.hgnc-m0.001-o0.0.tbl": "sha256:81eb754118e27e854974301b1400fcf519489f8be5249239671fb288cb501c31",
46
+ "motif2tf/motifs-v10nr_clust-nr.mgi-m0.001-o0.0.tbl": "sha256:5b64aad9df9804d50c50484c92d5192bdd5d2056cb105bdd343c0af2f94cce83",
47
+ },
48
+ )
49
+
50
+ return _motif_index
51
+
52
+
53
+ def fetch_motif_collection() -> str:
54
+ """
55
+ Download motif collection (motif names and PWM) from aertslab' pycistarget resources.
56
+
57
+ Returns
58
+ -------
59
+ Path to downloaded motif collection folder
60
+
61
+ Examples
62
+ --------
63
+ >>> motif_dir = fetch_motif_collection()
64
+ >>> print(motif_dir)
65
+ """
66
+ # Mapping of collection names to registry keys
67
+ name = "v10nr_clust" # only one collection name
68
+ collection_mapping = {
69
+ "v10nr_clust": "motif_collections/v10nr_clust_public/v10nr_clust_public.zip",
70
+ }
71
+
72
+ registry_key = collection_mapping[name]
73
+
74
+ def _extract_singletons(fname, action, pooch_obj):
75
+ """Extract only the singletons folder from the zip file."""
76
+ extract_dir = Path(fname).parent / Path(fname).stem / "singletons"
77
+ extract_dir.mkdir(parents=True, exist_ok=True)
78
+
79
+ with zipfile.ZipFile(fname, "r") as zip_file:
80
+ singletons_files = [
81
+ f for f in zip_file.namelist() if f.startswith("v10nr_clust_public/singletons/") and not f.endswith("/")
82
+ ]
83
+ for file_path in singletons_files:
84
+ file_name = Path(file_path).name
85
+ with zip_file.open(file_path) as source:
86
+ with open(extract_dir / file_name, "wb") as target:
87
+ target.write(source.read())
88
+ return str(extract_dir)
89
+
90
+ motif_dir = _get_motif_index().fetch(registry_key, processor=_extract_singletons, progressbar=True)
91
+
92
+ return motif_dir
93
+
94
+
95
+ def fetch_motif_annotations(species: str = "hgnc", version: str = "v10nr_clust") -> str:
96
+ """
97
+ Download motif annotations from aertslab resources.
98
+
99
+ Parameters
100
+ ----------
101
+ species
102
+ Species name. Available options:
103
+ - 'hgnc' (human): v9, v10nr_clust
104
+ - 'mgi' (mouse): v9, v10nr_clust
105
+ - 'flybase' (fly): v8, v9, v10nr_clust
106
+ - 'chicken': v10nr_clust only
107
+ version
108
+ Motif collection version. Available options: 'v8', 'v9', 'v10nr_clust'
109
+
110
+ Returns
111
+ -------
112
+ Path to downloaded annotations file
113
+
114
+ Examples
115
+ --------
116
+ >>> annotations_file = fetch_motif_annotations("hgnc", "v10nr_clust")
117
+ >>> print(annotations_file)
118
+ /path/to/cache/motifs-v10nr_clust-nr.hgnc-m0.001-o0.0.tbl
119
+ """
120
+ # Mapping of species and versions to registry keys
121
+ annotation_mapping = {
122
+ ("flybase", "v8"): "motif2tf/motifs-v8-nr.flybase-m0.001-o0.0.tbl",
123
+ ("flybase", "v9"): "motif2tf/motifs-v9-nr.flybase-m0.001-o0.0.tbl",
124
+ ("hgnc", "v9"): "motif2tf/motifs-v9-nr.hgnc-m0.001-o0.0.tbl",
125
+ ("mgi", "v9"): "motif2tf/motifs-v9-nr.mgi-m0.001-o0.0.tbl",
126
+ ("chicken", "v10nr_clust"): "motif2tf/motifs-v10nr_clust-nr.chicken-m0.001-o0.0.tbl",
127
+ ("flybase", "v10nr_clust"): "motif2tf/motifs-v10nr_clust-nr.flybase-m0.001-o0.0.tbl",
128
+ ("hgnc", "v10nr_clust"): "motif2tf/motifs-v10nr_clust-nr.hgnc-m0.001-o0.0.tbl",
129
+ ("mgi", "v10nr_clust"): "motif2tf/motifs-v10nr_clust-nr.mgi-m0.001-o0.0.tbl",
130
+ }
131
+
132
+ key = (species, version)
133
+ assert key in annotation_mapping, (
134
+ f"Species {species} with version {version} is not recognised. "
135
+ f"Available combinations: {list(annotation_mapping.keys())}"
136
+ )
137
+
138
+ registry_key = annotation_mapping[key]
139
+
140
+ annotations_file = _get_motif_index().fetch(registry_key, progressbar=True)
141
+
142
+ return annotations_file
143
+
144
+
145
+ def load_motif(file_name: str) -> dict[tuple[str, str], np.ndarray]:
146
+ """
147
+ Load motif(s) from a single .cb file.
148
+
149
+ Scales the PWM values to sum to 1 across each position (PPM).
150
+
151
+ Parameters
152
+ ----------
153
+ file_name
154
+ Path to the .cb motif file
155
+
156
+ Returns
157
+ -------
158
+ dict[tuple[str, str], np.ndarray]
159
+ Dictionary mapping motif file names and names to PWM matrices (4 x length)
160
+
161
+ Examples
162
+ --------
163
+ >>> motifs = load_motif("./motif1.cb")
164
+ >>> print(list(motifs.keys()))
165
+ [("filename_1", "motif_1"), ("filename_2", "motif_2")]
166
+ >>> print(motifs[("filename_1", "motif_1")].shape)
167
+ (4, 12)
168
+ """
169
+ motifs: dict[tuple[str, str], np.ndarray] = {}
170
+ with open(file_name) as f:
171
+ name = f.readline().strip()
172
+ if not name.startswith(">"):
173
+ raise ValueError(f"First line of {file_name} does not start with '>'.")
174
+ name = name.replace(">", "")
175
+ key = (os.path.basename(file_name).replace(".cb", ""), name)
176
+ pwm: list[list[float]] = []
177
+ for line in f:
178
+ line = line.strip()
179
+ if line.startswith(">"):
180
+ # we are at the start of a new motif
181
+ motifs[key] = np.array(pwm)
182
+ # scale values of motif
183
+ motifs[key] = motifs[key].T / motifs[key].sum(1)
184
+ # reset pwm and read new name
185
+ name = line.replace(">", "")
186
+ key = (os.path.basename(file_name).replace(".cb", ""), name)
187
+ pwm = []
188
+ else:
189
+ # we are in the middle of reading the pwm values
190
+ pwm.append([float(v) for v in line.split()])
191
+ # add last motif
192
+ motifs[key] = np.array(pwm)
193
+ # scale values of motif
194
+ motifs[key] = motifs[key].T / motifs[key].sum(1)
195
+ return motifs
196
+
197
+
198
+ def load_motif_collection(motif_dir: str, motif_names: list[str] | None = None) -> dict[tuple[str, str], np.ndarray]:
199
+ """
200
+ Load motif collection from directory of .cb files.
201
+
202
+ Converts motif PWM matrices to PPM (position probability matrix) format.
203
+
204
+ Parameters
205
+ ----------
206
+ motif_dir
207
+ Directory path containing .cb motif files
208
+ motif_names
209
+ Optional list of specific motif names to load. If None, loads all motifs.
210
+
211
+ Returns
212
+ -------
213
+ dict[tuple[str, str], np.ndarray]
214
+ Dictionary mapping motif names to PWM matrices (4 x length)
215
+
216
+ Examples
217
+ --------
218
+ >>> motifs = load_motif_collection("./motif_collection/")
219
+ >>> print(list(motifs.keys()))
220
+ [("filename_1", "motif_1"), ("filename_1", "motif_2"), ("filename_1", "motif_4")]
221
+ >>> print(motifs[("filename_1", "motif_1")].shape)
222
+ (4, 12)
223
+
224
+ >>> # Load only specific motifs
225
+ >>> selected_motifs = load_motif_collection("./motif_collection/", ["motif1", "motif2"])
226
+ >>> print(list(selected_motifs.keys()))
227
+ ['motif1', 'motif2']
228
+ """
229
+ motif_dir_path = Path(motif_dir)
230
+
231
+ if not motif_dir_path.exists():
232
+ raise FileNotFoundError(f"Directory {motif_dir_path} does not exist")
233
+
234
+ motifs: dict[tuple[str, str], np.ndarray] = {}
235
+
236
+ cb_files = list(motif_dir_path.glob("*.cb"))
237
+
238
+ for cb_file in cb_files:
239
+ try:
240
+ # Load all motifs from this file
241
+ file_motifs = load_motif(str(cb_file))
242
+ motifs.update(file_motifs)
243
+ except (ValueError, IndexError):
244
+ # Skip malformed files
245
+ continue
246
+
247
+ # Filter motifs if specific names are provided
248
+ if motif_names is not None:
249
+ motif_names_set = set(motif_names)
250
+ motifs = {(filename, name): pwm for (filename, name), pwm in motifs.items() if name in motif_names_set}
251
+
252
+ return motifs
253
+
254
+
255
+ def load_motif_annotations(
256
+ annotations_file: str,
257
+ motif_similarity_fdr: float = 0.001,
258
+ orthologous_identity_threshold: float = 0.0,
259
+ column_names: tuple[str, ...] = (
260
+ "#motif_id",
261
+ "gene_name",
262
+ "motif_similarity_qvalue",
263
+ "orthologous_identity",
264
+ "description",
265
+ ),
266
+ ) -> pd.DataFrame:
267
+ """
268
+ Load motif annotations from a motif2TF TSV file with filtering and categorization.
269
+
270
+ Parameters
271
+ ----------
272
+ annotations_file
273
+ Path to the annotations TSV file
274
+ motif_similarity_fdr
275
+ Maximum False Discovery Rate for enriched motifs (default: 0.001)
276
+ orthologous_identity_threshold
277
+ Minimum orthologous identity for enriched motifs (default: 0.0)
278
+ column_names
279
+ Column names to load from the TSV file
280
+
281
+ Returns
282
+ -------
283
+ DataFrame with motif annotations categorized by annotation type:
284
+ - Direct_annot: Direct gene annotations
285
+ - Motif_similarity_annot: Annotations by motif similarity
286
+ - Orthology_annot: Annotations by orthology
287
+ - Motif_similarity_and_Orthology_annot: Combined annotations
288
+
289
+ Examples
290
+ --------
291
+ >>> annotations = load_motif_annotations("./annotations.tbl")
292
+ >>> print(annotations.columns.tolist())
293
+ ['Direct_annot', 'Motif_similarity_annot', 'Orthology_annot', 'Motif_similarity_and_Orthology_annot']
294
+ """
295
+ # Load as pandas DataFrame
296
+ df = pd.read_csv(annotations_file, sep="\t", usecols=column_names)
297
+ df.rename(
298
+ columns={
299
+ "#motif_id": "MotifID",
300
+ "gene_name": "TF",
301
+ "motif_similarity_qvalue": "MotifSimilarityQvalue",
302
+ "orthologous_identity": "OrthologousIdentity",
303
+ "description": "Annotation",
304
+ },
305
+ inplace=True,
306
+ )
307
+
308
+ # Filter based on thresholds
309
+ df = df[
310
+ (df["MotifSimilarityQvalue"] <= motif_similarity_fdr)
311
+ & (df["OrthologousIdentity"] >= orthologous_identity_threshold)
312
+ ]
313
+
314
+ # Direct annotation
315
+ df_direct_annot = df[df["Annotation"] == "gene is directly annotated"]
316
+ df_direct_annot = df_direct_annot.groupby(["MotifID"])["TF"].apply(lambda x: ", ".join(list(set(x)))).reset_index()
317
+ df_direct_annot = df_direct_annot.set_index("MotifID")
318
+ df_direct_annot = pd.DataFrame(df_direct_annot["TF"])
319
+ df_direct_annot.columns = ["Direct_annot"]
320
+
321
+ # Indirect annotation - by motif similarity
322
+ motif_similarity_annot = df[
323
+ df["Annotation"].str.contains("similar") & ~df["Annotation"].str.contains("orthologous")
324
+ ]
325
+ motif_similarity_annot = (
326
+ motif_similarity_annot.groupby(["MotifID"])["TF"].apply(lambda x: ", ".join(list(set(x)))).reset_index()
327
+ )
328
+ motif_similarity_annot = motif_similarity_annot.set_index("MotifID")
329
+ motif_similarity_annot = pd.DataFrame(motif_similarity_annot["TF"])
330
+ motif_similarity_annot.columns = ["Motif_similarity_annot"]
331
+
332
+ # Indirect annotation - by orthology
333
+ orthology_annot = df[~df["Annotation"].str.contains("similar") & df["Annotation"].str.contains("orthologous")]
334
+ orthology_annot = orthology_annot.groupby(["MotifID"])["TF"].apply(lambda x: ", ".join(list(set(x)))).reset_index()
335
+ orthology_annot = orthology_annot.set_index("MotifID")
336
+ orthology_annot = pd.DataFrame(orthology_annot["TF"])
337
+ orthology_annot.columns = ["Orthology_annot"]
338
+
339
+ # Indirect annotation - by motif similarity and orthology
340
+ motif_similarity_and_orthology_annot = df[
341
+ df["Annotation"].str.contains("similar") & df["Annotation"].str.contains("orthologous")
342
+ ]
343
+ motif_similarity_and_orthology_annot = (
344
+ motif_similarity_and_orthology_annot.groupby(["MotifID"])["TF"]
345
+ .apply(lambda x: ", ".join(list(set(x))))
346
+ .reset_index()
347
+ )
348
+ motif_similarity_and_orthology_annot = motif_similarity_and_orthology_annot.set_index("MotifID")
349
+ motif_similarity_and_orthology_annot = pd.DataFrame(motif_similarity_and_orthology_annot["TF"])
350
+ motif_similarity_and_orthology_annot.columns = ["Motif_similarity_and_Orthology_annot"]
351
+
352
+ # Combine all annotation types
353
+ result = pd.concat(
354
+ [df_direct_annot, motif_similarity_annot, orthology_annot, motif_similarity_and_orthology_annot],
355
+ axis=1,
356
+ sort=False,
357
+ )
358
+
359
+ return result
360
+
361
+
362
+ def load_motif_to_dbd(motif_annotations: pd.DataFrame) -> dict[str, str]:
363
+ """
364
+ Create motif-to-DNA-binding-domain mapping for human TFs.
365
+
366
+ Takes motif annotations and maps motifs to their DNA-binding domains
367
+ based on TF annotations and human TF database information.
368
+
369
+ Parameters
370
+ ----------
371
+ motif_annotations
372
+ DataFrame with motif annotations as returned by load_motif_annotations()
373
+
374
+ Returns
375
+ -------
376
+ dict[str, str]
377
+ Dictionary mapping motif IDs to DNA-binding domain names
378
+
379
+ Examples
380
+ --------
381
+ >>> annotations_file = fetch_motif_annotations("hgnc", "v10nr_clust")
382
+ >>> motif_annotations = load_motif_annotations(annotations_file)
383
+ >>> motif_to_dbd = load_motif_to_dbd(motif_annotations)
384
+ >>> print(motif_to_dbd["hocomoco__FOXO1_HUMAN.H11MO.0.A"])
385
+ 'Forkhead'
386
+ """
387
+ motif_to_tf = motif_annotations.copy()
388
+
389
+ # Flatten all TF annotations into individual TF names
390
+ motif_to_tf = (
391
+ motif_to_tf.apply(lambda row: ", ".join(row.dropna()), axis=1)
392
+ .str.split(", ")
393
+ .explode()
394
+ .reset_index()
395
+ .rename({0: "TF"}, axis=1)
396
+ )
397
+
398
+ # Download human TF annotations with DNA-binding domains
399
+ human_tf_annot = pd.read_csv(
400
+ "https://humantfs.ccbr.utoronto.ca/download/v_1.01/DatabaseExtract_v_1.01.csv",
401
+ index_col=0,
402
+ )[["HGNC symbol", "DBD"]]
403
+
404
+ motif_to_tf = motif_to_tf.merge(right=human_tf_annot, how="left", left_on="TF", right_on="HGNC symbol")
405
+
406
+ # For each motif, take the most common (mode) DBD annotation
407
+ motif_to_dbd = (
408
+ motif_to_tf.dropna()
409
+ .groupby("MotifID")["DBD"]
410
+ .agg(lambda x: x.mode().iat[0]) # take the first mode if there's a tie
411
+ .reset_index()
412
+ )
413
+
414
+ motif_to_dbd = motif_to_dbd.set_index("MotifID")["DBD"].to_dict()
415
+
416
+ return motif_to_dbd