Coverage for barbet/embedding.py: 32.82%

326 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2026-08-17 06:22 +0000

1import gzip 

2import os 

3from pathlib import Path 

4from abc import ABC, abstractmethod 

5from Bio import SeqIO 

6import random 

7import numpy as np 

8from rich.progress import track 

9from hierarchicalsoftmax import SoftmaxNode 

10from hierarchicalsoftmax import TreeDict 

11import tarfile 

12import torch 

13from io import StringIO 

14from torchapp.cli import CLIApp, tool, method 

15import typer 

16from dataclasses import dataclass 

17 

18from .data import read_memmap, RANKS 

19 

20 

21def _open(path, mode='rt', **kwargs): 

22 """ 

23 Open a file normally, or with gzip if it ends in .gz. 

24  

25 Args: 

26 path (str or Path): The path to the file. 

27 mode (str): The mode to open the file with (default 'rt' for reading text). 

28 **kwargs: Additional arguments passed to open or gzip.open. 

29 

30 Returns: 

31 A file object. 

32 """ 

33 path = Path(path) 

34 if path.suffix == '.gz': 

35 return gzip.open(path, mode, **kwargs) 

36 return open(path, mode, **kwargs) 

37 

38 

39def set_validation_rank_to_treedict( 

40 treedict:TreeDict, 

41 validation_rank:str="species", 

42 partitions:int=5, 

43) -> TreeDict: 

44 # find the taxonomic rank to use for the validation partition 

45 validation_rank = validation_rank.lower() 

46 assert validation_rank in RANKS 

47 validation_rank_index = RANKS.index(validation_rank) 

48 

49 partitions_dict = {} 

50 for key in treedict: 

51 node = treedict.node(key) 

52 # Assign validation partition at set rank 

53 partition_node = node.ancestors[validation_rank_index] 

54 if partition_node not in partitions_dict: 

55 partitions_dict[partition_node] = random.randint(0,partitions-1) 

56 

57 treedict[key].partition = partitions_dict[partition_node] 

58 

59 return treedict 

60 

61 

62def get_key(accession:str, gene:str) -> str: 

63 """ Returns the standard format of a key """ 

64 key = f"{accession}/{gene}" 

65 return key 

66 

67 

68def get_node(lineage:str, lineage_to_node:dict[str,SoftmaxNode]) -> SoftmaxNode: 

69 if lineage in lineage_to_node: 

70 return lineage_to_node[lineage] 

71 

72 assert ";" in lineage, f"Semi-colon ';' not found in lineage '{lineage}'" 

73 split_point = lineage.rfind(";") 

74 parent_lineage = lineage[:split_point] 

75 name = lineage[split_point+1:] 

76 parent = get_node(parent_lineage, lineage_to_node) 

77 node = SoftmaxNode(name=name, parent=parent) 

78 lineage_to_node[lineage] = node 

79 return node 

80 

81 

82def generate_overlapping_intervals(total: int, interval_size: int, min_overlap: int, check:bool=True, variable_size:bool=False) -> list[tuple[int, int]]: 

83 """ 

84 Creates a list of overlapping intervals within a specified range, adjusting the interval size to ensure 

85 that the overlap is approximately the same across all intervals. 

86 

87 Args: 

88 total (int): The total range within which intervals are to be created. 

89 max_interval_size (int): The maximum size of each interval. 

90 min_overlap (int): The minimum number of units by which consecutive intervals overlap. 

91 check (bool): If True, checks are performed to ensure that the intervals meet the specified conditions. 

92 

93 Returns: 

94 list[tuple[int, int]]: A list of tuples where each tuple represents the start (inclusive)  

95 and end (exclusive) of an interval. 

96 

97 Example: 

98 >>> generate_overlapping_intervals(20, 5, 2) 

99 [(0, 5), (3, 8), (6, 11), (9, 14), (12, 17), (15, 20)] 

100 """ 

101 intervals = [] 

102 start = 0 

103 

104 if total == 0: 

105 return intervals 

106 

107 max_interval_size = interval_size 

108 assert interval_size 

109 assert min_overlap is not None 

110 assert interval_size > min_overlap, f"Max interval size of {interval_size} must be greater than min overlap of {min_overlap}" 

111 

112 # Calculate the number of intervals needed to cover the range 

113 num_intervals, remainder = divmod(total - min_overlap, interval_size - min_overlap) 

114 if remainder > 0: 

115 num_intervals += 1 

116 

117 # Calculate the exact interval size to ensure consistent overlap 

118 overlap = min_overlap 

119 if variable_size: 

120 if num_intervals > 1: 

121 interval_size, remainder = divmod(total + (num_intervals - 1) * overlap, num_intervals) 

122 if remainder > 0: 

123 interval_size += 1 

124 else: 

125 # If the size is fixed, then vary the overlap to keep it even 

126 if num_intervals > 1: 

127 overlap, remainder = divmod( num_intervals * interval_size - total, num_intervals - 1) 

128 if overlap < min_overlap: 

129 overlap = min_overlap 

130 

131 while True: 

132 end = start + interval_size 

133 if end > total: 

134 end = total 

135 start = max(end - interval_size,0) 

136 intervals.append((start, end)) 

137 start += interval_size - overlap 

138 if end >= total: 

139 break 

140 

141 if check: 

142 assert intervals[0][0] == 0 

143 assert intervals[-1][1] == total 

144 assert len(intervals) == num_intervals, f"Expected {num_intervals} intervals, got {len(intervals)}" 

145 

146 assert interval_size <= max_interval_size, f"Interval size of {interval_size} exceeds max interval size of {max_interval_size}" 

147 for interval in intervals: 

148 assert interval[1] - interval[0] == interval_size, f"Interval size of {interval[1] - interval[0]} is not the expected size {interval_size}" 

149 

150 for i in range(1, len(intervals)): 

151 overlap = intervals[i - 1][1] - intervals[i][0] 

152 assert overlap >= min_overlap, f"Min overlap condition of {min_overlap} not met for intervals {intervals[i - 1]} and {intervals[i]} (overlap {overlap})" 

153 

154 return intervals 

155 

156 

157@dataclass 

158class Embedding(CLIApp, ABC): 

159 """ A class for embedding protein sequences. """ 

160 max_length:int|None=None 

161 overlap:int=64 

162 

163 def __post_init__(self): 

164 super().__init__() 

165 if hasattr(self, "main_app") and hasattr(self.main_app, "info"): 

166 self.main_app.info.context_settings = {"help_option_names": ["-h", "--help"]} 

167 if hasattr(self, "tools_app") and hasattr(self.tools_app, "info"): 

168 self.tools_app.info.context_settings = {"help_option_names": ["-h", "--help"]} 

169 

170 @abstractmethod 

171 def embed(self, seq:str) -> torch.Tensor: 

172 """ Takes a protein sequence as a string and returns an embedding vector. """ 

173 raise NotImplementedError 

174 

175 def reduce(self, tensor:torch.Tensor) -> torch.Tensor: 

176 if tensor.ndim == 2: 

177 tensor = tensor.mean(dim=0) 

178 assert tensor.ndim == 1 

179 return tensor 

180 

181 def __call__(self, seq:str) -> torch.Tensor: 

182 """ Takes a protein sequence as a string and returns an embedding vector. """ 

183 if not self.max_length or len(seq) <= self.max_length: 

184 tensor = self.embed(seq) 

185 return self.reduce(tensor) 

186 

187 epsilon = 0.1 

188 intervals = generate_overlapping_intervals(len(seq), self.max_length, self.overlap) 

189 weights = torch.zeros( (len(seq),), device="cpu" ) 

190 tensor = None 

191 for start,end in intervals: 

192 result = self.embed(seq[start:end]).cpu() 

193 

194 assert result.shape[0] == end-start 

195 embedding_size = result.shape[1] 

196 

197 if tensor is None: 

198 tensor = torch.zeros( (len(seq), embedding_size ), device="cpu") 

199 

200 assert tensor.shape[-1] == embedding_size 

201 

202 interval_indexes = torch.arange(end-start) 

203 distance_from_ends = torch.min( interval_indexes-start, end-interval_indexes-1 ) 

204 

205 weight = epsilon + torch.minimum(distance_from_ends, torch.tensor(self.overlap)) 

206 

207 tensor[start:end] += result * weight.unsqueeze(1) 

208 weights[start:end] += weight 

209 

210 tensor = tensor/weights.unsqueeze(1) 

211 

212 return self.reduce(tensor) 

213 

214 @method 

215 def setup(self, **kwargs): 

216 pass 

217 

218 def build_treedict(self, taxonomy:Path) -> tuple[TreeDict,dict[str,SoftmaxNode]]: 

219 # Create root of tree 

220 lineage_to_node = {} 

221 root = None 

222 

223 # Fill out tree with taxonomy 

224 accession_to_node = {} 

225 with _open(taxonomy) as f: 

226 for line in f: 

227 accesssion, lineage = line.split("\t") 

228 

229 if not root: 

230 root_name = lineage.split(";")[0] 

231 root = SoftmaxNode(root_name) 

232 lineage_to_node[root_name] = root 

233 

234 node = get_node(lineage, lineage_to_node) 

235 accession_to_node[accesssion] = node 

236 

237 treedict = TreeDict(classification_tree=root) 

238 return treedict, accession_to_node 

239 

240 @tool("setup") 

241 def test_lengths( 

242 self, 

243 end:int=5_000, 

244 start:int=1000, 

245 retries:int=5, 

246 **kwargs, 

247 ): 

248 def random_amino_acid_sequence(k): 

249 amino_acids = "ACDEFGHIKLMNPQRSTVWY" # standard 20 amino acids 

250 return ''.join(random.choice(amino_acids) for _ in range(k)) 

251 

252 self.max_length = None 

253 self.setup(**kwargs) 

254 for ii in track(range(start,end)): 

255 for _ in range(retries): 

256 seq = random_amino_acid_sequence(ii) 

257 try: 

258 self(seq) 

259 except Exception as err: 

260 print(f"{ii}: {err}") 

261 return 

262 

263 

264 @tool("setup") 

265 def build_gene_array( 

266 self, 

267 marker_genes:Path=typer.Option(default=..., help="The path to the marker genes tarball (e.g. bac120_msa_marker_genes_all_r220.tar.gz)."), 

268 family_index:int=typer.Option(default=..., help="The index for the gene family to use. E.g. if there are 120 gene families then this should be a number from 0 to 119."), 

269 output_dir:Path=typer.Option(default=..., help="A directory to store the output which includes the memmap array, the listing of accessions and an error log."), 

270 flush_every:int=typer.Option(default=5_000, help="An interval to flush the memmap array as it is generated."), 

271 max_length:int=None, 

272 **kwargs, 

273 ): 

274 self.max_length = max_length 

275 self.setup(**kwargs) 

276 

277 assert marker_genes is not None 

278 assert family_index is not None 

279 assert output_dir is not None 

280 

281 dtype = 'float16' 

282 

283 memmap_wip_array = None 

284 output_dir.mkdir(parents=True, exist_ok=True) 

285 memmap_wip_path = output_dir / f"{family_index}-wip.npy" 

286 error = output_dir / f"{family_index}-errors.txt" 

287 accessions_wip = output_dir / f"{family_index}-accessions-wip.txt" 

288 

289 accessions = [] 

290 

291 print(f"Loading {marker_genes} file.") 

292 with tarfile.open(marker_genes, "r:gz") as tar, open(error, "w") as error_file, open(accessions_wip, "w") as accessions_wip_file: 

293 members = [member for member in tar.getmembers() if member.isfile() and member.name.endswith(".faa")] 

294 prefix_length = len(os.path.commonprefix([Path(member.name).with_suffix("").name for member in members])) 

295 

296 member = members[family_index] 

297 print(f"Processing file {family_index} in {marker_genes}") 

298 

299 f = tar.extractfile(member) 

300 marker_id = Path(member.name).with_suffix("").name[prefix_length:] 

301 

302 fasta_io = StringIO(f.read().decode('ascii')) 

303 

304 total = sum(1 for _ in SeqIO.parse(fasta_io, "fasta")) 

305 fasta_io.seek(0) 

306 print(marker_id, total) 

307 

308 for record in track(SeqIO.parse(fasta_io, "fasta"), total=total): 

309 # for record in SeqIO.parse(fasta_io, "fasta"): 

310 species_accession = record.id 

311 

312 key = get_key(species_accession, marker_id) 

313 

314 seq = str(record.seq).replace("-","").replace("*","") 

315 try: 

316 vector = self(seq) 

317 except Exception as err: 

318 print(f"{key} ({len(seq)}): {err}", file=error_file) 

319 print(f"{key} ({len(seq)}): {err}") 

320 continue 

321 

322 if vector is None: 

323 print(f"{key} ({len(seq)}): Embedding is None", file=error_file) 

324 print(f"{key} ({len(seq)}): Embedding is None") 

325 continue 

326 

327 if torch.isnan(vector).any(): 

328 print(f"{key} ({len(seq)}): Embedding contains NaN", file=error_file) 

329 print(f"{key} ({len(seq)}): Embedding contains NaN") 

330 continue 

331 

332 if memmap_wip_array is None: 

333 size = len(vector) 

334 shape = (total,size) 

335 memmap_wip_array = np.memmap(memmap_wip_path, dtype=dtype, mode='w+', shape=shape) 

336 

337 index = len(accessions) 

338 memmap_wip_array[index,:] = vector.cpu().half().numpy() 

339 if index % flush_every == 0: 

340 memmap_wip_array.flush() 

341 

342 accessions.append(key) 

343 print(key, file=accessions_wip_file) 

344 

345 memmap_wip_array.flush() 

346 

347 accessions_path = output_dir / f"{family_index}.txt" 

348 with open(accessions_path, "w") as f: 

349 for accession in accessions: 

350 print(accession, file=f) 

351 

352 # Save final memmap array now that we now the final size 

353 memmap_path = output_dir / f"{family_index}.npy" 

354 shape = (len(accessions),size) 

355 print(f"Writing final memmap array of shape {shape}: {memmap_path}") 

356 memmap_array = np.memmap(memmap_path, dtype=dtype, mode='w+', shape=shape) 

357 memmap_array[:len(accessions),:] = memmap_wip_array[:len(accessions),:] 

358 memmap_array.flush() 

359 

360 # Clean up 

361 memmap_array._mmap.close() 

362 memmap_array._mmap = None 

363 memmap_array = None 

364 memmap_wip_path.unlink() 

365 accessions_wip.unlink() 

366 

367 @tool 

368 def set_validation_rank( 

369 self, 

370 treedict:Path=typer.Option(default=..., help="The path to the treedict file."), 

371 output:Path=typer.Option(default=..., help="The path to save the adapted treedict file."), 

372 validation_rank:str=typer.Option(default="species", help="The rank to hold out for cross-validation."), 

373 partitions:int=typer.Option(default=5, help="The number of cross-validation partitions."), 

374 ) -> TreeDict: 

375 treedict = TreeDict.load(treedict) 

376 set_validation_rank_to_treedict(treedict, validation_rank=validation_rank, partitions=partitions) 

377 treedict.save(output) 

378 return treedict 

379 

380 @tool 

381 def preprocess( 

382 self, 

383 taxonomy:Path=typer.Option(default=..., help="The path to the TSV taxonomy file (e.g. bac120_taxonomy_r220.tsv)."), 

384 marker_genes:Path=typer.Option(default=..., help="The path to the marker genes tarball (e.g. bac120_msa_marker_genes_all_r220.tar.gz)."), 

385 output_dir:Path=typer.Option(default=..., help="A directory to store the output which includes the memmap array, the listing of accessions and an error log."), 

386 partitions:int=typer.Option(default=5, help="The number of cross-validation partitions."), 

387 seed:int=typer.Option(default=42, help="The random seed."), 

388 treedict_only:bool=typer.Option(default=False, help="Only output TreeDict file and then exit before concatenating memmap array"), 

389 ): 

390 treedict, accession_to_node = self.build_treedict(taxonomy) 

391 

392 dtype = 'float16' 

393 

394 random.seed(seed) 

395 

396 print(f"Loading {marker_genes} file.") 

397 with tarfile.open(marker_genes, "r:gz") as tar: 

398 members = [member for member in tar.getmembers() if member.isfile() and member.name.endswith(".faa")] 

399 family_count = len(members) 

400 print(f"{family_count} gene families found.") 

401 

402 # Read and collect accessions 

403 print(f"Building treedict") 

404 keys = [] 

405 counts = [] 

406 node_to_partition_dict = dict() 

407 for family_index in track(range(family_count)): 

408 keys_path = output_dir / f"{family_index}.txt" 

409 

410 if not keys_path.exists(): 

411 counts.append(0) 

412 continue 

413 

414 with open(keys_path) as f: 

415 family_index_keys = [line.strip() for line in f] 

416 keys += family_index_keys 

417 counts.append(len(family_index_keys)) 

418 

419 for key in family_index_keys: 

420 genome_accession = key.split("/")[0] 

421 node = accession_to_node[genome_accession] 

422 partition = node_to_partition_dict.setdefault(node, random.randint(0, partitions - 1)) 

423 

424 # Add to treedict 

425 treedict.add(key, node, partition) 

426 

427 assert len(counts) == family_count 

428 

429 # Save treedict 

430 treedict_path = output_dir / f"{output_dir.name}.td" 

431 print(f"Saving TreeDict to {treedict_path}") 

432 treedict.save(treedict_path) 

433 

434 if treedict_only: 

435 return 

436 

437 # Concatenate numpy memmap arrays 

438 memmap_array = None 

439 memmap_array_path = output_dir / f"{output_dir.name}.npy" 

440 print(f"Saving memmap to {memmap_array_path}") 

441 current_index = 0 

442 for family_index, family_count in track(enumerate(counts), total=len(counts)): 

443 my_memmap_path = output_dir / f"{family_index}.npy" 

444 

445 # Build memmap for gene family if it doesn't exist 

446 if not my_memmap_path.exists(): 

447 continue 

448 # print("Building", my_memmap_path) 

449 # self.build_gene_array(marker_genes=marker_genes, family_index=family_index, output_dir=output_dir) 

450 # assert my_memmap_path.exists() 

451 

452 my_memmap = read_memmap(my_memmap_path, family_count) 

453 

454 # Build memmap for output if it doesn't exist 

455 if memmap_array is None: 

456 size = my_memmap.shape[1] 

457 shape = (len(keys),size) 

458 memmap_array = np.memmap(memmap_array_path, dtype=dtype, mode='w+', shape=shape) 

459 

460 # Copy memmap for gene family into output memmap 

461 memmap_array[current_index:current_index+family_count,:] = my_memmap[:,:] 

462 

463 current_index += family_count 

464 

465 assert len(keys) == current_index 

466 

467 memmap_array.flush() 

468 

469 # Save keys 

470 keys_path = output_dir / f"{output_dir.name}.txt" 

471 print(f"Saving keys to {keys_path}") 

472 with open(keys_path, "w") as f: 

473 for key in keys: 

474 print(key, file=f) 

475 

476 @tool 

477 def prune_to_representatives(treedict:Path, representatives:Path, output:Path): 

478 print("Getting list of representatives from", representatives) 

479 keys_to_keep = [] 

480 with tarfile.open(representatives, "r:gz") as tar: 

481 members = [member for member in tar.getmembers() if member.isfile() and member.name.endswith(".faa")] 

482 

483 print(f"Processing {len(members)} files in {representatives}") 

484 

485 for member in track(members): 

486 f = tar.extractfile(member) 

487 marker_id = Path(member.name.split("_")[-1]).with_suffix("").name 

488 

489 fasta_io = StringIO(f.read().decode('ascii')) 

490 

491 for record in SeqIO.parse(fasta_io, "fasta"): 

492 species_accession = record.id 

493 key = get_key(species_accession, marker_id) 

494 keys_to_keep.append(key) 

495 

496 # keys_to_keep = set(keys_to_keep) 

497 print(f"Keeping {len(keys_to_keep)} representatives") 

498 

499 print(f"Loading treedict {treedict}") 

500 

501 treedict = TreeDict.load(treedict) 

502 print("Total", len(treedict)) 

503 missing = [] 

504 for key in track(keys_to_keep): 

505 if key not in treedict: 

506 missing.append(key) 

507 

508 print(f"{len(missing)} representatives missing output {len(keys_to_keep)} (total: {len(treedict)})") 

509 if len(missing): 

510 keys_to_keep = [k for k in keys_to_keep if k not in missing] 

511 

512 new_treedict = TreeDict(treedict.classification_tree) 

513 new_treedict.update({k:treedict[k] for k in keys_to_keep}) 

514 print("Total after pruning", len(new_treedict)) 

515 

516 print("Saving treedict to", output) 

517 new_treedict.save(output)