mesklintech commited on
Commit
424c56c
·
verified ·
1 Parent(s): 2b9f360

Publish BioVoice-TTS sparse energy checkpoint and model card

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +136 -0
  2. bio_llm/__init__.py +1 -0
  3. bio_llm/model/__init__.py +1 -0
  4. bio_llm/model/candidate_retrieval.py +61 -0
  5. bio_llm/model/embedding.py +18 -0
  6. bio_llm/model/energy_head.py +96 -0
  7. bio_llm/model/laminar_layer.py +40 -0
  8. bio_llm/model/low_rank_qkv.py +35 -0
  9. bio_llm/model/model.py +98 -0
  10. bio_llm/model/sparse_attention.py +143 -0
  11. bio_llm/training/__init__.py +1 -0
  12. bio_llm/training/chat_dataset.py +183 -0
  13. bio_llm/training/interview_dataset.py +101 -0
  14. bio_llm/training/loss.py +65 -0
  15. bio_llm/training/metrics.py +291 -0
  16. bio_llm/training/recipes.py +133 -0
  17. bio_llm/training/trainer.py +308 -0
  18. bio_llm/utils/__init__.py +1 -0
  19. bio_llm/utils/config.py +37 -0
  20. bio_llm/utils/export.py +47 -0
  21. bio_llm/utils/tokenizer.py +277 -0
  22. bio_voice_tts/TRAINING_INFRASTRUCTURE.md +964 -0
  23. bio_voice_tts/__init__.py +6 -0
  24. bio_voice_tts/audio/__init__.py +1 -0
  25. bio_voice_tts/audio/features.py +101 -0
  26. bio_voice_tts/audio/mel.py +36 -0
  27. bio_voice_tts/audio/phoneme.py +21 -0
  28. bio_voice_tts/audio/stft.py +41 -0
  29. bio_voice_tts/benchmarks/__init__.py +1 -0
  30. bio_voice_tts/benchmarks/benchmark_cpu.py +32 -0
  31. bio_voice_tts/configs/acoustic_decoder.yaml +14 -0
  32. bio_voice_tts/configs/base.yaml +43 -0
  33. bio_voice_tts/configs/datasets.yaml +6 -0
  34. bio_voice_tts/configs/speaker_encoder.yaml +8 -0
  35. bio_voice_tts/configs/vocoder.yaml +9 -0
  36. bio_voice_tts/datasets/__init__.py +1 -0
  37. bio_voice_tts/datasets/manifest.py +26 -0
  38. bio_voice_tts/datasets/speaker_dataset.py +59 -0
  39. bio_voice_tts/datasets/tts_dataset.py +133 -0
  40. bio_voice_tts/evaluation/__init__.py +1 -0
  41. bio_voice_tts/evaluation/evaluate.py +33 -0
  42. bio_voice_tts/evaluation/metrics.py +17 -0
  43. bio_voice_tts/inference/__init__.py +1 -0
  44. bio_voice_tts/inference/clone_voice.py +44 -0
  45. bio_voice_tts/inference/realtime_stream.py +5 -0
  46. bio_voice_tts/inference/synthesize.py +32 -0
  47. bio_voice_tts/model/__init__.py +1 -0
  48. bio_voice_tts/model/acoustic_decoder.py +66 -0
  49. bio_voice_tts/model/biovoice_tts.py +91 -0
  50. bio_voice_tts/model/laminar.py +32 -0
README.md ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ license: other
5
+ library_name: pytorch
6
+ pipeline_tag: text-to-speech
7
+ tags:
8
+ - text-to-speech
9
+ - voice-cloning
10
+ - sparse-attention
11
+ - low-rank
12
+ - cpu-first
13
+ - ljspeech
14
+ - biovoice-tts
15
+ datasets:
16
+ - keithito/lj_speech
17
+ ---
18
+
19
+ # BioVoice-TTS Sparse Energy Voice Model
20
+
21
+ BioVoice-TTS is a CPU-first text-to-speech and voice-cloning research model built around the same sparse-energy design principles as Bio-LLM/SSET:
22
+
23
+ - low-rank Q/K/V projections
24
+ - causal sparse candidate attention
25
+ - local, memory, landmark, and content candidate routing
26
+ - laminar excitatory/inhibitory refinement
27
+ - explicit speaker conditioning
28
+ - explicit duration, pitch, and energy prediction
29
+ - sparse acoustic decoding with an energy/gated mel head
30
+
31
+ This upload contains a compact LJSpeech-trained text-to-mel checkpoint. The optimizer state and intermediate checkpoints were intentionally omitted to keep the repository small.
32
+
33
+ ## Files
34
+
35
+ - `model.safetensors`: model-only weights converted from local checkpoint `step_8000.pt`
36
+ - `config.json`: BioVoice-TTS architecture and training config
37
+ - `tokenizer.json`: phoneme/character tokenizer used for the LJSpeech run
38
+ - `training_summary.json`: checkpoint metrics summary
39
+ - `training_metrics_step_8000.json`: raw metrics saved with the selected checkpoint
40
+ - `bio_voice_tts/`: model, audio feature, dataset, training, inference, streaming, and vocoder code
41
+ - `bio_llm/`: shared sparse-energy language-model utilities used by the project
42
+
43
+ ## Checkpoint Metrics
44
+
45
+ Selected checkpoint: `step_8000`
46
+
47
+ | Metric | Value |
48
+ |---|---:|
49
+ | loss | 1.7827 |
50
+ | mel_loss / mel_mae | 1.6115 |
51
+ | duration_loss | 0.0077 |
52
+ | pitch_loss | 0.3701 |
53
+ | energy_loss | 1.3269 |
54
+ | speaker_cosine proxy | 1.0000 |
55
+
56
+ These are internal training metrics from the local run, not standardized MOS, WER, speaker-verification EER, or cross-model benchmark scores.
57
+
58
+ ## Architecture Path
59
+
60
+ The real forward path is:
61
+
62
+ 1. Reference mel -> `SpeakerEncoder`
63
+ 2. Text tokens -> `SemanticEncoder`
64
+ 3. Semantic states + speaker latent -> FiLM conditioning
65
+ 4. Duration predictor -> length regulation
66
+ 5. Pitch and energy predictors -> frame-level controls
67
+ 6. Frame states + speaker + pitch + energy -> `SparseAcousticDecoder`
68
+ 7. Acoustic energy/gating head -> mel spectrogram
69
+ 8. Optional `SparseNeuralVocoder` -> waveform
70
+
71
+ The model is not a wrapper around Tacotron, FastSpeech, VITS, XTTS, StyleTTS, or F5-TTS. It is a custom sparse-energy TTS architecture. Some config fields are reserved or scaffolded and are not fully wired yet; see the limitations section.
72
+
73
+ ## Minimal Loading Example
74
+
75
+ ```python
76
+ import json
77
+ import torch
78
+ from safetensors.torch import load_file
79
+
80
+ from bio_voice_tts import BioVoiceConfig, BioVoiceTTS
81
+
82
+ def merge_dataclass(instance, payload):
83
+ for key, value in payload.items():
84
+ current = getattr(instance, key)
85
+ if hasattr(current, "__dataclass_fields__") and isinstance(value, dict):
86
+ merge_dataclass(current, value)
87
+ else:
88
+ setattr(instance, key, value)
89
+ return instance
90
+
91
+ config = merge_dataclass(BioVoiceConfig(), json.load(open("config.json")))
92
+ model = BioVoiceTTS(config)
93
+ model.load_state_dict(load_file("model.safetensors"), strict=False)
94
+ model.eval()
95
+
96
+ token_ids = torch.randint(0, config.semantic.vocab_size, (1, 16))
97
+ reference_mel = torch.randn(1, 128, config.audio.n_mels)
98
+
99
+ with torch.no_grad():
100
+ outputs = model(token_ids, reference_mel)
101
+
102
+ print(outputs["mel"].shape)
103
+ ```
104
+
105
+ For waveform synthesis, pass `outputs["mel"]` through `bio_voice_tts.vocoder.sparse_vocoder.SparseNeuralVocoder`. A separately trained vocoder checkpoint is recommended for production-quality audio.
106
+
107
+ ## Real Comparison Snapshot
108
+
109
+ This table compares design and deployment tradeoffs, not universal audio quality. BioVoice-TTS has not yet been evaluated with a public MOS/WER/EER benchmark suite against these systems.
110
+
111
+ | Model | Publicly known strength | Where BioVoice-TTS is different | Where BioVoice-TTS is currently weaker |
112
+ |---|---|---|---|
113
+ | Coqui XTTS-v2 | Mature multilingual voice cloning; model card states 17 languages and cloning from a short reference clip. Source: https://huggingface.co/coqui/XTTS-v2 | BioVoice-TTS is smaller here and built around sparse low-rank CPU-first modules rather than a large ready-to-use multilingual stack. | XTTS-v2 is more production-ready, multilingual, and widely tested. BioVoice-TTS currently has a single-speaker LJSpeech checkpoint and needs more evaluation. |
114
+ | StyleTTS 2 | Paper reports human-level/super-human judged naturalness on LJSpeech/VCTK settings using style diffusion and adversarial training with speech language models. Source: https://arxiv.org/abs/2306.07691 | BioVoice-TTS avoids diffusion/style sampling and emphasizes interpretable sparse memory, laminar refinement, and CPU-oriented low-rank compute. | StyleTTS 2 has stronger published quality claims. BioVoice-TTS does not yet have MOS evidence at that level. |
115
+ | F5-TTS | Flow-matching DiT system; paper emphasizes a simpler non-autoregressive design without explicit duration model/text encoder/phoneme alignment. Source: https://arxiv.org/abs/2410.06885 | BioVoice-TTS intentionally keeps explicit duration, pitch, energy, speaker, memory, and acoustic components for control and interpretability. | F5-TTS is a stronger modern zero-shot baseline for naturalness/voice cloning. BioVoice-TTS needs more training scale and public listening tests. |
116
+ | OpenVoice | Instant voice cloning from a short reference; paper focuses on flexible tone-color cloning and multilingual generation. Source: https://arxiv.org/abs/2312.01479 | BioVoice-TTS is an end-to-end sparse text-to-mel architecture with an optional neural vocoder, not mainly a tone-color conversion stack. | OpenVoice has a clearer instant-cloning product path. BioVoice-TTS still needs stronger speaker verification and cloning evaluations. |
117
+ | Older Tacotron/FastSpeech-style systems | Stable, well-known TTS baselines. | BioVoice-TTS has richer sparse attention, memory routing, laminar refinement, and explicit energy/gated acoustic decoding. | Older systems may have simpler tooling and better documented recipes; BioVoice-TTS is research-stage. |
118
+
119
+ ## Limitations
120
+
121
+ - This upload is primarily a research checkpoint and code release.
122
+ - It is trained on LJSpeech-style single-speaker data, not a broad multilingual/multi-speaker corpus.
123
+ - No standardized MOS, WER, speaker EER, latency benchmark, or human preference study is included yet.
124
+ - Some architecture config fields are currently scaffolded or hard-coded rather than fully parameterized.
125
+ - The published checkpoint is text-to-mel. For final audio quality, use or train a matching vocoder checkpoint.
126
+ - Do not use for impersonation, fraud, or cloning a voice without consent.
127
+
128
+ ## Suggested Evaluation Before Production
129
+
130
+ - MOS or MUSHRA-style listening test against XTTS-v2, StyleTTS 2, F5-TTS, and OpenVoice
131
+ - Speaker similarity with a real speaker verification model
132
+ - WER using an ASR model to verify intelligibility
133
+ - CPU latency at multiple text lengths
134
+ - Long-form stability and pronunciation tests
135
+ - Ablations for sparse memory, laminar refinement, and acoustic energy gates
136
+
bio_llm/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Bio-LLM package."""
bio_llm/model/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Model components for the Structured Sparse Energy Transformer."""
bio_llm/model/candidate_retrieval.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class CandidateRetriever(nn.Module):
8
+ """Two-stage candidate filtering to avoid a full-vocabulary energy pass."""
9
+
10
+ def __init__(self, vocab_size: int, d_model: int, stage1_dim: int, stage1_k: int, stage2_k: int):
11
+ super().__init__()
12
+ self.vocab_size = vocab_size
13
+ self.stage1_k = stage1_k
14
+ self.stage2_k = stage2_k
15
+ self.query_low = nn.Parameter(torch.empty(d_model, stage1_dim))
16
+ self.vocab_low = nn.Parameter(torch.empty(vocab_size, stage1_dim))
17
+ nn.init.normal_(self.query_low, mean=0.0, std=1.0 / math.sqrt(d_model))
18
+ nn.init.normal_(self.vocab_low, mean=0.0, std=1.0 / math.sqrt(stage1_dim))
19
+
20
+ def forward(
21
+ self,
22
+ hidden_states: torch.Tensor,
23
+ embedding_weight: torch.Tensor,
24
+ target_ids: torch.Tensor | None = None,
25
+ ) -> torch.Tensor:
26
+ batch_size, seq_len, _ = hidden_states.shape
27
+ low_queries = torch.matmul(hidden_states, self.query_low)
28
+ low_scores = torch.matmul(low_queries, self.vocab_low.transpose(0, 1))
29
+ stage1_k = min(self.stage1_k, self.vocab_size)
30
+ coarse_candidates = torch.topk(low_scores, k=stage1_k, dim=-1).indices
31
+ final_candidates = torch.zeros(
32
+ batch_size, seq_len, self.stage2_k, dtype=torch.long, device=hidden_states.device
33
+ )
34
+
35
+ for batch_index in range(batch_size):
36
+ for position in range(seq_len):
37
+ candidate_ids = coarse_candidates[batch_index, position]
38
+ candidate_embeddings = embedding_weight[candidate_ids]
39
+ fine_scores = torch.matmul(candidate_embeddings, hidden_states[batch_index, position])
40
+
41
+ if target_ids is not None:
42
+ target_id = int(target_ids[batch_index, position].item())
43
+ if target_id not in candidate_ids.tolist():
44
+ candidate_ids = torch.cat(
45
+ [candidate_ids[:-1], torch.tensor([target_id], device=hidden_states.device)]
46
+ )
47
+ candidate_embeddings = embedding_weight[candidate_ids]
48
+ fine_scores = torch.matmul(candidate_embeddings, hidden_states[batch_index, position])
49
+
50
+ stage2_k = min(self.stage2_k, candidate_ids.numel())
51
+ top_indices = torch.topk(fine_scores, k=stage2_k).indices
52
+ chosen = candidate_ids[top_indices]
53
+
54
+ if target_ids is not None:
55
+ target_id = int(target_ids[batch_index, position].item())
56
+ if target_id not in chosen.tolist():
57
+ chosen[-1] = target_id
58
+
59
+ final_candidates[batch_index, position, :stage2_k] = chosen[:stage2_k]
60
+
61
+ return final_candidates
bio_llm/model/embedding.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+
4
+
5
+ class TokenEmbedding(nn.Module):
6
+ """Maps token ids to continuous vectors x_t = W_embed[token]."""
7
+
8
+ def __init__(self, vocab_size: int, d_model: int):
9
+ super().__init__()
10
+ self.embedding = nn.Embedding(vocab_size, d_model)
11
+ nn.init.normal_(self.embedding.weight, mean=0.0, std=0.02)
12
+
13
+ @property
14
+ def weight(self) -> torch.Tensor:
15
+ return self.embedding.weight
16
+
17
+ def forward(self, token_ids: torch.Tensor) -> torch.Tensor:
18
+ return self.embedding(token_ids)
bio_llm/model/energy_head.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class FactorizedTransitionBias(nn.Module):
8
+ """Approximates transition_bias(prev_token, y) with a low-rank factorization."""
9
+
10
+ def __init__(self, vocab_size: int, rank: int):
11
+ super().__init__()
12
+ self.prev_factor = nn.Embedding(vocab_size, rank)
13
+ self.next_factor = nn.Embedding(vocab_size, rank)
14
+ nn.init.normal_(self.prev_factor.weight, mean=0.0, std=1.0 / math.sqrt(rank))
15
+ nn.init.normal_(self.next_factor.weight, mean=0.0, std=1.0 / math.sqrt(rank))
16
+
17
+ def forward(self, prev_tokens: torch.Tensor, candidate_ids: torch.Tensor) -> torch.Tensor:
18
+ prev_repr = self.prev_factor(prev_tokens).unsqueeze(-2)
19
+ next_repr = self.next_factor(candidate_ids)
20
+ return torch.sum(prev_repr * next_repr, dim=-1)
21
+
22
+
23
+ class EnergyHead(nn.Module):
24
+ """Computes E(y) and P(y) on a sparse candidate set."""
25
+
26
+ def __init__(self, vocab_size: int, d_model: int, transition_rank: int):
27
+ super().__init__()
28
+ self.transition_bias = FactorizedTransitionBias(vocab_size, transition_rank)
29
+ self.d_model = d_model
30
+ self.log_temperature = nn.Parameter(torch.zeros(1))
31
+ self.temperature_floor = 0.5
32
+
33
+ def _context_embedding_summary(
34
+ self,
35
+ input_ids: torch.Tensor,
36
+ attention_indices: torch.Tensor,
37
+ attention_weights: torch.Tensor,
38
+ embedding_weight: torch.Tensor,
39
+ ) -> torch.Tensor:
40
+ batch_size, seq_len, _ = attention_indices.shape
41
+ summary = torch.zeros(batch_size, seq_len, self.d_model, device=input_ids.device, dtype=embedding_weight.dtype)
42
+ for batch_index in range(batch_size):
43
+ for position in range(seq_len):
44
+ token_positions = attention_indices[batch_index, position]
45
+ token_ids = input_ids[batch_index, token_positions]
46
+ token_embeddings = embedding_weight[token_ids]
47
+ weights = attention_weights[batch_index, position].unsqueeze(-1)
48
+ summary[batch_index, position] = torch.sum(token_embeddings * weights, dim=0)
49
+ return summary
50
+
51
+ def forward(
52
+ self,
53
+ hidden_states: torch.Tensor,
54
+ input_ids: torch.Tensor,
55
+ prev_tokens: torch.Tensor,
56
+ candidate_ids: torch.Tensor,
57
+ attention_indices: torch.Tensor,
58
+ attention_weights: torch.Tensor,
59
+ embedding_weight: torch.Tensor,
60
+ ) -> dict[str, torch.Tensor]:
61
+ candidate_embeddings = embedding_weight[candidate_ids]
62
+ context_summary = self._context_embedding_summary(
63
+ input_ids=input_ids,
64
+ attention_indices=attention_indices,
65
+ attention_weights=attention_weights,
66
+ embedding_weight=embedding_weight,
67
+ )
68
+
69
+ e_sim = -torch.sum(hidden_states.unsqueeze(-2) * candidate_embeddings, dim=-1)
70
+ e_ctx = -torch.sum(context_summary.unsqueeze(-2) * candidate_embeddings, dim=-1)
71
+ e_mem = -self.transition_bias(prev_tokens, candidate_ids)
72
+
73
+ confidence = attention_weights.max(dim=-1).values
74
+ g1 = confidence
75
+ g2 = 1.0 - torch.abs(0.5 - confidence)
76
+ g3 = 1.0 - confidence
77
+ energies = g1.unsqueeze(-1) * e_sim + g2.unsqueeze(-1) * e_ctx + g3.unsqueeze(-1) * e_mem
78
+
79
+ temperature = self.temperature_floor + torch.nn.functional.softplus(self.log_temperature)
80
+ scaled_energies = energies / temperature
81
+
82
+ log_probs = -scaled_energies - torch.logsumexp(-scaled_energies, dim=-1, keepdim=True)
83
+ probabilities = torch.exp(log_probs)
84
+ return {
85
+ "energies": energies,
86
+ "scaled_energies": scaled_energies,
87
+ "log_probs": log_probs,
88
+ "probabilities": probabilities,
89
+ "temperature": temperature,
90
+ "e_sim": e_sim,
91
+ "e_ctx": e_ctx,
92
+ "e_mem": e_mem,
93
+ "g1": g1,
94
+ "g2": g2,
95
+ "g3": g3,
96
+ }
bio_llm/model/laminar_layer.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+
4
+
5
+ class LaminarRefinement(nn.Module):
6
+ """Applies h_i <- h_i + eta (E_i - I_i) for a few sparse iterations."""
7
+
8
+ def __init__(self, steps: int = 2, eta: float = 0.1):
9
+ super().__init__()
10
+ self.steps = steps
11
+ self.eta = eta
12
+
13
+ def _weighted_sum(
14
+ self,
15
+ states: torch.Tensor,
16
+ attention_indices: torch.Tensor,
17
+ attention_weights: torch.Tensor,
18
+ ) -> torch.Tensor:
19
+ batch_size, seq_len, _ = states.shape
20
+ mixed = torch.zeros_like(states)
21
+ for batch_index in range(batch_size):
22
+ for position in range(seq_len):
23
+ indices = attention_indices[batch_index, position]
24
+ weights = attention_weights[batch_index, position].unsqueeze(-1)
25
+ mixed[batch_index, position] = torch.sum(states[batch_index, indices] * weights, dim=0)
26
+ return mixed
27
+
28
+ def forward(
29
+ self,
30
+ states: torch.Tensor,
31
+ attention_indices: torch.Tensor,
32
+ attention_weights: torch.Tensor,
33
+ ) -> torch.Tensor:
34
+ refined = states
35
+ for _ in range(self.steps):
36
+ excitatory = self._weighted_sum(refined, attention_indices, attention_weights)
37
+ prefix_totals = refined.cumsum(dim=1)
38
+ inhibitory = prefix_totals - excitatory
39
+ refined = refined + self.eta * (excitatory - inhibitory)
40
+ return refined
bio_llm/model/low_rank_qkv.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class LowRankLinear(nn.Module):
8
+ """Computes xW with W = UV^T without materializing the full matrix."""
9
+
10
+ def __init__(self, in_dim: int, out_dim: int, rank: int):
11
+ super().__init__()
12
+ self.left = nn.Parameter(torch.empty(in_dim, rank))
13
+ self.right = nn.Parameter(torch.empty(out_dim, rank))
14
+ self.reset_parameters()
15
+
16
+ def reset_parameters(self) -> None:
17
+ nn.init.normal_(self.left, mean=0.0, std=1.0 / math.sqrt(self.left.size(0)))
18
+ nn.init.normal_(self.right, mean=0.0, std=1.0 / math.sqrt(self.right.size(1)))
19
+
20
+ def forward(self, inputs: torch.Tensor) -> torch.Tensor:
21
+ hidden = torch.matmul(inputs, self.left)
22
+ return torch.matmul(hidden, self.right.transpose(0, 1))
23
+
24
+
25
+ class LowRankQKV(nn.Module):
26
+ """Builds efficient Q, K, V projections with low-rank factors."""
27
+
28
+ def __init__(self, d_model: int, rank: int):
29
+ super().__init__()
30
+ self.q_proj = LowRankLinear(d_model, d_model, rank)
31
+ self.k_proj = LowRankLinear(d_model, d_model, rank)
32
+ self.v_proj = LowRankLinear(d_model, d_model, rank)
33
+
34
+ def forward(self, inputs: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
35
+ return self.q_proj(inputs), self.k_proj(inputs), self.v_proj(inputs)
bio_llm/model/model.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+
4
+ from bio_llm.model.candidate_retrieval import CandidateRetriever
5
+ from bio_llm.model.embedding import TokenEmbedding
6
+ from bio_llm.model.energy_head import EnergyHead
7
+ from bio_llm.model.laminar_layer import LaminarRefinement
8
+ from bio_llm.model.sparse_attention import SparseAttention
9
+ from bio_llm.utils.config import SSETConfig
10
+
11
+
12
+ class StructuredSparseEnergyTransformer(nn.Module):
13
+ """CPU-oriented language model with sparse interaction and energy decoding."""
14
+
15
+ def __init__(self, config: SSETConfig):
16
+ super().__init__()
17
+ self.config = config
18
+ self.embedding = TokenEmbedding(config.vocab_size, config.d_model)
19
+ self.sparse_attention = SparseAttention(
20
+ d_model=config.d_model,
21
+ rank=config.low_rank,
22
+ max_seq_len=config.max_seq_len,
23
+ top_k=config.attention_top_k,
24
+ local_window=config.local_window,
25
+ memory_candidates=config.memory_candidates,
26
+ landmark_count=config.landmark_count,
27
+ content_memory_candidates=config.content_memory_candidates,
28
+ )
29
+ self.laminar = LaminarRefinement(steps=config.laminar_steps, eta=config.laminar_eta)
30
+ self.retriever = CandidateRetriever(
31
+ vocab_size=config.vocab_size,
32
+ d_model=config.d_model,
33
+ stage1_dim=config.stage1_dim,
34
+ stage1_k=config.retrieval_stage1_k,
35
+ stage2_k=config.retrieval_stage2_k,
36
+ )
37
+ self.energy_head = EnergyHead(
38
+ vocab_size=config.vocab_size,
39
+ d_model=config.d_model,
40
+ transition_rank=config.transition_rank,
41
+ )
42
+
43
+ def forward(
44
+ self,
45
+ input_ids: torch.Tensor,
46
+ target_ids: torch.Tensor | None = None,
47
+ attention_mode: str | None = None,
48
+ ) -> dict[str, torch.Tensor]:
49
+ embedded = self.embedding(input_ids)
50
+ attention_state = self.sparse_attention(embedded, mode=attention_mode or self.config.attention_mode)
51
+ refined_states = self.laminar(
52
+ states=attention_state.context,
53
+ attention_indices=attention_state.attention_indices,
54
+ attention_weights=attention_state.attention_weights,
55
+ )
56
+ candidate_ids = self.retriever(
57
+ hidden_states=refined_states,
58
+ embedding_weight=self.embedding.weight,
59
+ target_ids=target_ids,
60
+ )
61
+ energy_outputs = self.energy_head(
62
+ hidden_states=refined_states,
63
+ input_ids=input_ids,
64
+ prev_tokens=input_ids,
65
+ candidate_ids=candidate_ids,
66
+ attention_indices=attention_state.attention_indices,
67
+ attention_weights=attention_state.attention_weights,
68
+ embedding_weight=self.embedding.weight,
69
+ )
70
+ return {
71
+ "hidden_states": refined_states,
72
+ "candidate_ids": candidate_ids,
73
+ "attention_indices": attention_state.attention_indices,
74
+ "attention_weights": attention_state.attention_weights,
75
+ "confidence": attention_state.confidence,
76
+ **energy_outputs,
77
+ }
78
+
79
+ @torch.no_grad()
80
+ def generate(
81
+ self,
82
+ prompt_ids: list[int],
83
+ eos_id: int,
84
+ max_new_tokens: int = 12,
85
+ attention_mode: str | None = None,
86
+ ) -> list[int]:
87
+ generated = list(prompt_ids)
88
+ for _ in range(max_new_tokens):
89
+ window = generated[-self.config.max_seq_len :]
90
+ input_ids = torch.tensor([window], dtype=torch.long)
91
+ outputs = self.forward(input_ids, attention_mode=attention_mode)
92
+ last_probabilities = outputs["probabilities"][0, -1]
93
+ last_candidates = outputs["candidate_ids"][0, -1]
94
+ next_token = int(last_candidates[last_probabilities.argmax()].item())
95
+ generated.append(next_token)
96
+ if next_token == eos_id:
97
+ break
98
+ return generated
bio_llm/model/sparse_attention.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ import math
3
+
4
+ import torch
5
+ from torch import nn
6
+
7
+ from .low_rank_qkv import LowRankQKV
8
+
9
+
10
+ @dataclass
11
+ class SparseAttentionState:
12
+ context: torch.Tensor
13
+ attention_weights: torch.Tensor
14
+ attention_indices: torch.Tensor
15
+ confidence: torch.Tensor
16
+
17
+
18
+ def sparsemax(scores: torch.Tensor) -> torch.Tensor:
19
+ """Sparsemax is a simple entmax-like normalization with exact zeros."""
20
+
21
+ if scores.numel() == 1:
22
+ return torch.ones_like(scores)
23
+
24
+ sorted_scores, _ = torch.sort(scores, descending=True)
25
+ cumulative = torch.cumsum(sorted_scores, dim=0) - 1.0
26
+ steps = torch.arange(1, scores.numel() + 1, device=scores.device, dtype=scores.dtype)
27
+ support = sorted_scores - cumulative / steps > 0
28
+ support_size = int(max(1, support.sum().item()))
29
+ tau = cumulative[support_size - 1] / steps[support_size - 1]
30
+ output = torch.clamp(scores - tau, min=0.0)
31
+ total = output.sum()
32
+ if total <= 0:
33
+ return torch.softmax(scores, dim=0)
34
+ return output / total
35
+
36
+
37
+ class SparseAttention(nn.Module):
38
+ """Causal sparse interaction with top-k or sparsemax normalization."""
39
+
40
+ def __init__(
41
+ self,
42
+ d_model: int,
43
+ rank: int,
44
+ max_seq_len: int,
45
+ top_k: int,
46
+ local_window: int,
47
+ memory_candidates: int,
48
+ landmark_count: int,
49
+ content_memory_candidates: int,
50
+ ):
51
+ super().__init__()
52
+ self.qkv = LowRankQKV(d_model, rank)
53
+ self.top_k = top_k
54
+ self.local_window = local_window
55
+ self.memory_candidates = memory_candidates
56
+ self.landmark_count = landmark_count
57
+ self.content_memory_candidates = content_memory_candidates
58
+ self.max_candidates = local_window + memory_candidates + landmark_count + content_memory_candidates
59
+ self.channel_logits = nn.Parameter(torch.tensor([1.0, 0.4, 0.4]))
60
+ self.memory_bias = nn.Parameter(torch.zeros(max_seq_len))
61
+ self.landmark_logits = nn.Parameter(torch.zeros(max_seq_len))
62
+ self.scale = 1.0 / math.sqrt(d_model)
63
+
64
+ def _candidate_positions(
65
+ self,
66
+ position: int,
67
+ query: torch.Tensor,
68
+ prior_keys: torch.Tensor,
69
+ ) -> list[int]:
70
+ local_start = max(0, position - self.local_window + 1)
71
+ local_positions = list(range(local_start, position + 1))
72
+ memory_budget = min(self.memory_candidates, position + 1)
73
+ top_distances = torch.topk(self.memory_bias[: position + 1], k=memory_budget).indices.tolist()
74
+ memory_positions = [position - int(distance) for distance in top_distances]
75
+ landmark_budget = min(self.landmark_count, position + 1)
76
+ top_landmarks = torch.topk(self.landmark_logits[: position + 1], k=landmark_budget).indices.tolist()
77
+
78
+ content_positions: list[int] = []
79
+ if position > 0 and self.content_memory_candidates > 0:
80
+ similarity = torch.matmul(prior_keys, query) * self.scale
81
+ content_budget = min(self.content_memory_candidates, similarity.numel())
82
+ content_positions = torch.topk(similarity, k=content_budget).indices.tolist()
83
+
84
+ merged = sorted(set(local_positions + memory_positions + top_landmarks + content_positions))
85
+ return merged[: self.max_candidates]
86
+
87
+ def _normalize(self, scores: torch.Tensor, mode: str) -> torch.Tensor:
88
+ if mode == "topk":
89
+ keep = min(self.top_k, scores.numel())
90
+ values, indices = torch.topk(scores, k=keep)
91
+ weights = torch.zeros_like(scores)
92
+ weights[indices] = torch.softmax(values, dim=0)
93
+ return weights
94
+ if mode == "sparsemax":
95
+ return sparsemax(scores)
96
+ raise ValueError(f"Unsupported sparse attention mode: {mode}")
97
+
98
+ def forward(self, inputs: torch.Tensor, mode: str = "topk") -> SparseAttentionState:
99
+ queries, keys, values = self.qkv(inputs)
100
+ batch_size, seq_len, d_model = queries.shape
101
+
102
+ attention_indices = torch.zeros(
103
+ batch_size, seq_len, self.max_candidates, dtype=torch.long, device=inputs.device
104
+ )
105
+ attention_weights = torch.zeros(
106
+ batch_size, seq_len, self.max_candidates, dtype=inputs.dtype, device=inputs.device
107
+ )
108
+ context = torch.zeros(batch_size, seq_len, d_model, dtype=inputs.dtype, device=inputs.device)
109
+ channel_weights = torch.softmax(self.channel_logits, dim=0)
110
+
111
+ for batch_index in range(batch_size):
112
+ for position in range(seq_len):
113
+ prior_keys = keys[batch_index, : position + 1]
114
+ candidates = self._candidate_positions(position, queries[batch_index, position], prior_keys)
115
+ candidate_tensor = torch.tensor(candidates, dtype=torch.long, device=inputs.device)
116
+ candidate_count = candidate_tensor.numel()
117
+ attention_indices[batch_index, position, :candidate_count] = candidate_tensor
118
+
119
+ q_i = queries[batch_index, position]
120
+ k_subset = keys[batch_index, candidate_tensor]
121
+ v_subset = values[batch_index, candidate_tensor]
122
+
123
+ content_term = torch.matmul(k_subset, q_i) * self.scale
124
+ distances = (position - candidate_tensor).to(inputs.dtype)
125
+ positional_term = -distances
126
+ memory_term = self.memory_bias[position - candidate_tensor]
127
+ scores = (
128
+ channel_weights[0] * content_term
129
+ + channel_weights[1] * positional_term
130
+ + channel_weights[2] * memory_term
131
+ )
132
+
133
+ normalized = self._normalize(scores, mode=mode)
134
+ attention_weights[batch_index, position, :candidate_count] = normalized
135
+ context[batch_index, position] = torch.sum(normalized.unsqueeze(-1) * v_subset, dim=0)
136
+
137
+ confidence = attention_weights.max(dim=-1).values
138
+ return SparseAttentionState(
139
+ context=context,
140
+ attention_weights=attention_weights,
141
+ attention_indices=attention_indices,
142
+ confidence=confidence,
143
+ )
bio_llm/training/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Training utilities for the Structured Sparse Energy Transformer."""
bio_llm/training/chat_dataset.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterable, Sequence
5
+
6
+
7
+ USER_ROLES = {"user", "human", "prompter", "customer", "client"}
8
+ ASSISTANT_ROLES = {"assistant", "gpt", "bot", "model", "response"}
9
+ SYSTEM_ROLES = {"system", "moderator"}
10
+ RAW_TURN_PATTERN = re.compile(
11
+ r"<start_of_turn>\s*(?P<role>[a-zA-Z_]+)\s*\n(?P<content>.*?)<end_of_turn>",
12
+ flags=re.DOTALL,
13
+ )
14
+
15
+
16
+ def normalize_role(role: str) -> str:
17
+ role_key = role.strip().lower()
18
+ if role_key in USER_ROLES:
19
+ return "User"
20
+ if role_key in ASSISTANT_ROLES:
21
+ return "Assistant"
22
+ if role_key in SYSTEM_ROLES:
23
+ return "System"
24
+ if not role.strip():
25
+ return "User"
26
+ return role.strip().title()
27
+
28
+
29
+ def _extract_text(message: object) -> tuple[str, str]:
30
+ if isinstance(message, dict):
31
+ role = str(message.get("role", message.get("from", message.get("speaker", ""))))
32
+ content = message.get("content", message.get("value", message.get("text", "")))
33
+ return role, str(content)
34
+
35
+ if isinstance(message, (list, tuple)) and len(message) >= 2:
36
+ return str(message[0]), str(message[1])
37
+
38
+ return "", str(message)
39
+
40
+
41
+ def extract_messages(example: dict[str, object]) -> list[tuple[str, str]]:
42
+ if "messages" in example and isinstance(example["messages"], Sequence) and not isinstance(example["messages"], (str, bytes)):
43
+ return [_extract_text(message) for message in example["messages"]]
44
+
45
+ if "conversations" in example and isinstance(example["conversations"], Sequence) and not isinstance(
46
+ example["conversations"], (str, bytes)
47
+ ):
48
+ return [_extract_text(message) for message in example["conversations"]]
49
+
50
+ if "dialog" in example and isinstance(example["dialog"], Sequence) and not isinstance(example["dialog"], (str, bytes)):
51
+ return [_extract_text(message) for message in example["dialog"]]
52
+
53
+ if "prompt" in example and "response" in example:
54
+ return [("user", str(example["prompt"])), ("assistant", str(example["response"]))]
55
+
56
+ if "instruction" in example and "output" in example:
57
+ return [("user", str(example["instruction"])), ("assistant", str(example["output"]))]
58
+
59
+ if "input" in example and "output" in example:
60
+ return [("user", str(example["input"])), ("assistant", str(example["output"]))]
61
+
62
+ if "raw_text_content" in example:
63
+ text = str(example["raw_text_content"])
64
+ messages = [(match.group("role"), match.group("content")) for match in RAW_TURN_PATTERN.finditer(text)]
65
+ if messages:
66
+ return messages
67
+
68
+ return []
69
+
70
+
71
+ def _clean_text(text: str) -> str:
72
+ return " ".join(str(text).split()).strip()
73
+
74
+
75
+ def conversation_to_text(
76
+ example: dict[str, object],
77
+ max_turns: int | None = None,
78
+ max_message_chars: int | None = None,
79
+ ) -> str:
80
+ messages = extract_messages(example)
81
+ if max_turns is not None:
82
+ messages = messages[: max(1, max_turns)]
83
+
84
+ lines: list[str] = []
85
+ for role, content in messages:
86
+ clean_content = _clean_text(content)
87
+ if max_message_chars is not None:
88
+ clean_content = clean_content[: max(1, max_message_chars)].strip()
89
+ if not clean_content:
90
+ continue
91
+ lines.append(f"{normalize_role(role)}: {clean_content}")
92
+ return "\n".join(lines).strip()
93
+
94
+
95
+ def conversation_to_pair(
96
+ example: dict[str, object],
97
+ max_turns: int | None = None,
98
+ max_message_chars: int | None = None,
99
+ ) -> dict[str, str] | None:
100
+ messages = extract_messages(example)
101
+ if max_turns is not None:
102
+ messages = messages[: max(1, max_turns)]
103
+
104
+ if not messages:
105
+ return None
106
+
107
+ first_user_index = None
108
+ first_assistant_index = None
109
+ for index, (role, content) in enumerate(messages):
110
+ role_key = role.strip().lower()
111
+ if first_user_index is None and role_key in USER_ROLES:
112
+ first_user_index = index
113
+ elif first_user_index is not None and role_key in ASSISTANT_ROLES:
114
+ first_assistant_index = index
115
+ break
116
+
117
+ if first_user_index is None or first_assistant_index is None:
118
+ return None
119
+
120
+ prompt_parts: list[str] = []
121
+ for role, content in messages[:first_assistant_index]:
122
+ clean_content = _clean_text(content)
123
+ if max_message_chars is not None:
124
+ clean_content = clean_content[: max(1, max_message_chars)].strip()
125
+ if not clean_content:
126
+ continue
127
+ prompt_parts.append(f"{normalize_role(role)}: {clean_content}")
128
+
129
+ assistant_role, assistant_content = messages[first_assistant_index]
130
+ assistant_text = _clean_text(assistant_content)
131
+ if max_message_chars is not None:
132
+ assistant_text = assistant_text[: max(1, max_message_chars)].strip()
133
+ if not prompt_parts or not assistant_text:
134
+ return None
135
+
136
+ prompt_text = "\n".join(prompt_parts + [f"{normalize_role(assistant_role)}:"]).strip()
137
+ sentence_text = f"{prompt_text} {assistant_text}".strip()
138
+ return {
139
+ "input": prompt_text,
140
+ "next_word": assistant_text,
141
+ "sentence": sentence_text,
142
+ }
143
+
144
+
145
+ def build_chat_corpus(
146
+ examples: Iterable[dict[str, object]],
147
+ max_turns: int | None = None,
148
+ max_message_chars: int | None = None,
149
+ ) -> str:
150
+ conversations = [
151
+ conversation_to_text(
152
+ example,
153
+ max_turns=max_turns,
154
+ max_message_chars=max_message_chars,
155
+ )
156
+ for example in examples
157
+ ]
158
+ conversations = [conversation for conversation in conversations if conversation]
159
+ if not conversations:
160
+ raise ValueError("No conversational text could be built from the dataset examples.")
161
+ return "\n\n".join(conversations)
162
+
163
+
164
+ def build_chat_pairs(
165
+ examples: Iterable[dict[str, object]],
166
+ max_turns: int | None = None,
167
+ max_message_chars: int | None = None,
168
+ ) -> list[dict[str, str]]:
169
+ pairs = [
170
+ pair
171
+ for example in examples
172
+ if (
173
+ pair := conversation_to_pair(
174
+ example,
175
+ max_turns=max_turns,
176
+ max_message_chars=max_message_chars,
177
+ )
178
+ )
179
+ is not None
180
+ ]
181
+ if not pairs:
182
+ raise ValueError("No conversational prompt/response pairs could be built from the dataset examples.")
183
+ return pairs
bio_llm/training/interview_dataset.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ from pathlib import Path
6
+
7
+
8
+ def load_interview_examples(path: str | Path) -> list[dict[str, str]]:
9
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
10
+ if not isinstance(payload, list):
11
+ raise ValueError(f"Expected a JSON list in {path}, got {type(payload).__name__}.")
12
+
13
+ examples: list[dict[str, str]] = []
14
+ for index, item in enumerate(payload, start=1):
15
+ if not isinstance(item, dict):
16
+ raise ValueError(f"Expected object items in {path} at index {index}, got {type(item).__name__}.")
17
+ for field in ("question", "answer"):
18
+ if field not in item:
19
+ raise ValueError(f"Missing field {field!r} in {path} at index {index}.")
20
+ examples.append(
21
+ {
22
+ "domain": str(item.get("domain", "")).strip(),
23
+ "question": str(item["question"]).strip(),
24
+ "answer": str(item["answer"]).strip(),
25
+ }
26
+ )
27
+
28
+ filtered = [example for example in examples if example["question"] and example["answer"]]
29
+ if not filtered:
30
+ raise ValueError(f"No usable interview examples found in {path}.")
31
+ return filtered
32
+
33
+
34
+ def format_interview_prompt(example: dict[str, str], include_domain: bool = True) -> str:
35
+ lines: list[str] = []
36
+ domain = example.get("domain", "").strip()
37
+ if include_domain and domain:
38
+ lines.append(f"Domain: {domain}")
39
+ lines.append(f"Question: {example['question'].strip()}")
40
+ lines.append("Answer:")
41
+ return "\n".join(lines).strip()
42
+
43
+
44
+ def build_interview_corpus(
45
+ examples: list[dict[str, str]],
46
+ include_domain: bool = True,
47
+ ) -> str:
48
+ documents = [
49
+ f"{format_interview_prompt(example, include_domain=include_domain)} {example['answer'].strip()}".strip()
50
+ for example in examples
51
+ ]
52
+ documents = [document for document in documents if document]
53
+ if not documents:
54
+ raise ValueError("No interview training documents could be built from the dataset.")
55
+ return "\n\n".join(documents)
56
+
57
+
58
+ def build_interview_pairs(
59
+ examples: list[dict[str, str]],
60
+ include_domain: bool = True,
61
+ ) -> list[dict[str, str]]:
62
+ pairs = [
63
+ {
64
+ "input": format_interview_prompt(example, include_domain=include_domain),
65
+ "next_word": example["answer"].strip(),
66
+ "sentence": (
67
+ f"{format_interview_prompt(example, include_domain=include_domain)} {example['answer'].strip()}".strip()
68
+ ),
69
+ }
70
+ for example in examples
71
+ if example["question"].strip() and example["answer"].strip()
72
+ ]
73
+ if not pairs:
74
+ raise ValueError("No interview prompt/answer pairs could be built from the dataset.")
75
+ return pairs
76
+
77
+
78
+ def split_interview_examples(
79
+ examples: list[dict[str, str]],
80
+ eval_fraction: float,
81
+ seed: int,
82
+ ) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
83
+ shuffled = list(examples)
84
+ random.Random(seed).shuffle(shuffled)
85
+
86
+ if len(shuffled) < 2 or eval_fraction <= 0:
87
+ return shuffled, []
88
+
89
+ eval_count = max(1, int(round(len(shuffled) * eval_fraction)))
90
+ eval_count = min(eval_count, len(shuffled) - 1)
91
+ train_examples = shuffled[:-eval_count]
92
+ eval_examples = shuffled[-eval_count:]
93
+ return train_examples, eval_examples
94
+
95
+
96
+ def dump_pairs_jsonl(pairs: list[dict[str, str]], path: str | Path) -> None:
97
+ output_path = Path(path)
98
+ output_path.write_text(
99
+ "\n".join(json.dumps(pair, ensure_ascii=True) for pair in pairs),
100
+ encoding="utf-8",
101
+ )
bio_llm/training/loss.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+ from torch.nn import functional as F
4
+
5
+
6
+ class StructuredEnergyLoss(nn.Module):
7
+ def __init__(
8
+ self,
9
+ margin: float,
10
+ margin_lambda: float,
11
+ ignore_index: int | None = None,
12
+ label_smoothing: float = 0.0,
13
+ hard_negative_count: int = 1,
14
+ ):
15
+ super().__init__()
16
+ self.margin = margin
17
+ self.margin_lambda = margin_lambda
18
+ self.ignore_index = ignore_index
19
+ self.label_smoothing = max(0.0, label_smoothing)
20
+ self.hard_negative_count = max(1, hard_negative_count)
21
+
22
+ def forward(
23
+ self,
24
+ energies: torch.Tensor,
25
+ log_probs: torch.Tensor,
26
+ candidate_ids: torch.Tensor,
27
+ target_ids: torch.Tensor,
28
+ ) -> tuple[torch.Tensor, dict[str, float]]:
29
+ target_mask = candidate_ids.eq(target_ids.unsqueeze(-1))
30
+ positive_log_probs = log_probs.masked_fill(~target_mask, float("-inf")).max(dim=-1).values
31
+ positive_energies = energies.masked_fill(~target_mask, float("inf")).min(dim=-1).values
32
+ negative_energies = energies.masked_fill(target_mask, float("inf"))
33
+ negative_scores = torch.where(
34
+ torch.isinf(negative_energies),
35
+ torch.full_like(negative_energies, float("-inf")),
36
+ -negative_energies,
37
+ )
38
+ hard_negative_count = min(self.hard_negative_count, negative_scores.size(-1))
39
+ hard_negative_energies = -torch.topk(negative_scores, k=hard_negative_count, dim=-1).values
40
+ hard_negative_energies = torch.where(
41
+ torch.isinf(hard_negative_energies),
42
+ positive_energies.detach().unsqueeze(-1),
43
+ hard_negative_energies,
44
+ )
45
+
46
+ if self.label_smoothing > 0:
47
+ uniform_log_probs = log_probs.mean(dim=-1)
48
+ nll = -(1.0 - self.label_smoothing) * positive_log_probs - self.label_smoothing * uniform_log_probs
49
+ else:
50
+ nll = -positive_log_probs
51
+
52
+ margin_term = F.relu(self.margin + positive_energies.unsqueeze(-1) - hard_negative_energies).mean(dim=-1)
53
+
54
+ if self.ignore_index is not None:
55
+ valid = target_ids.ne(self.ignore_index)
56
+ nll = nll[valid]
57
+ margin_term = margin_term[valid]
58
+
59
+ loss = nll.mean() + self.margin_lambda * margin_term.mean()
60
+ stats = {
61
+ "loss": float(loss.item()),
62
+ "nll": float(nll.mean().item()),
63
+ "margin": float(margin_term.mean().item()),
64
+ }
65
+ return loss, stats
bio_llm/training/metrics.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ import time
6
+
7
+ import torch
8
+ from torch.utils.data import DataLoader
9
+
10
+ from bio_llm.model.model import StructuredSparseEnergyTransformer
11
+ from bio_llm.utils.tokenizer import Tokenizer
12
+
13
+
14
+ def _valid_mask(target_ids: torch.Tensor, ignore_index: int | None) -> torch.Tensor:
15
+ if ignore_index is None:
16
+ return torch.ones_like(target_ids, dtype=torch.bool)
17
+ return target_ids.ne(ignore_index)
18
+
19
+
20
+ def _gather_target_log_probs(
21
+ log_probs: torch.Tensor,
22
+ candidate_ids: torch.Tensor,
23
+ target_ids: torch.Tensor,
24
+ ) -> tuple[torch.Tensor, torch.Tensor]:
25
+ target_mask = candidate_ids.eq(target_ids.unsqueeze(-1))
26
+ positive_log_probs = log_probs.masked_fill(~target_mask, float("-inf")).max(dim=-1).values
27
+ found_target = target_mask.any(dim=-1)
28
+ return positive_log_probs, found_target
29
+
30
+
31
+ def compute_prediction_metrics(
32
+ log_probs: torch.Tensor,
33
+ candidate_ids: torch.Tensor,
34
+ target_ids: torch.Tensor,
35
+ ignore_index: int | None = None,
36
+ top_k: int = 3,
37
+ ) -> dict[str, float]:
38
+ valid = _valid_mask(target_ids, ignore_index)
39
+ positive_log_probs, found_target = _gather_target_log_probs(log_probs, candidate_ids, target_ids)
40
+ effective = valid & found_target
41
+
42
+ if effective.sum().item() == 0:
43
+ return {
44
+ "candidate_nll": 0.0,
45
+ "candidate_perplexity": 1.0,
46
+ "top1_accuracy": 0.0,
47
+ "topk_accuracy": 0.0,
48
+ "coverage": 0.0,
49
+ }
50
+
51
+ top1_predictions = candidate_ids.gather(
52
+ -1,
53
+ log_probs.argmax(dim=-1, keepdim=True),
54
+ ).squeeze(-1)
55
+ top1_correct = top1_predictions.eq(target_ids) & effective
56
+
57
+ capped_top_k = min(top_k, candidate_ids.size(-1))
58
+ topk_candidates = torch.topk(log_probs, k=capped_top_k, dim=-1).indices
59
+ topk_predictions = candidate_ids.gather(-1, topk_candidates)
60
+ topk_correct = topk_predictions.eq(target_ids.unsqueeze(-1)).any(dim=-1) & effective
61
+
62
+ nll = -positive_log_probs[effective]
63
+ mean_nll = nll.mean().item()
64
+ return {
65
+ "candidate_nll": mean_nll,
66
+ "candidate_perplexity": math.exp(mean_nll),
67
+ "top1_accuracy": top1_correct.float().sum().item() / effective.sum().item(),
68
+ "topk_accuracy": topk_correct.float().sum().item() / effective.sum().item(),
69
+ "coverage": found_target[valid].float().mean().item(),
70
+ }
71
+
72
+
73
+ def compute_retrieval_recall(
74
+ candidate_ids: torch.Tensor,
75
+ target_ids: torch.Tensor,
76
+ ignore_index: int | None = None,
77
+ ) -> float:
78
+ valid = _valid_mask(target_ids, ignore_index)
79
+ if valid.sum().item() == 0:
80
+ return 0.0
81
+ recalled = candidate_ids.eq(target_ids.unsqueeze(-1)).any(dim=-1)
82
+ return recalled[valid].float().mean().item()
83
+
84
+
85
+ @torch.no_grad()
86
+ def evaluate_model(
87
+ model: StructuredSparseEnergyTransformer,
88
+ loader: DataLoader,
89
+ ignore_index: int | None = None,
90
+ attention_mode: str | None = None,
91
+ top_k: int = 3,
92
+ ) -> dict[str, float]:
93
+ was_training = model.training
94
+ model.eval()
95
+
96
+ total_tokens = 0
97
+ total_nll = 0.0
98
+ total_top1 = 0.0
99
+ total_topk = 0.0
100
+ total_coverage = 0.0
101
+ total_retrieval = 0.0
102
+
103
+ for input_ids, target_ids in loader:
104
+ scored_outputs = model(input_ids, target_ids=target_ids, attention_mode=attention_mode)
105
+ retrieval_outputs = model(input_ids, target_ids=None, attention_mode=attention_mode)
106
+
107
+ valid = _valid_mask(target_ids, ignore_index)
108
+ token_count = int(valid.sum().item())
109
+ if token_count == 0:
110
+ continue
111
+
112
+ prediction_metrics = compute_prediction_metrics(
113
+ log_probs=scored_outputs["log_probs"],
114
+ candidate_ids=scored_outputs["candidate_ids"],
115
+ target_ids=target_ids,
116
+ ignore_index=ignore_index,
117
+ top_k=top_k,
118
+ )
119
+ retrieval_recall = compute_retrieval_recall(
120
+ candidate_ids=retrieval_outputs["candidate_ids"],
121
+ target_ids=target_ids,
122
+ ignore_index=ignore_index,
123
+ )
124
+
125
+ total_tokens += token_count
126
+ total_nll += prediction_metrics["candidate_nll"] * token_count
127
+ total_top1 += prediction_metrics["top1_accuracy"] * token_count
128
+ total_topk += prediction_metrics["topk_accuracy"] * token_count
129
+ total_coverage += prediction_metrics["coverage"] * token_count
130
+ total_retrieval += retrieval_recall * token_count
131
+
132
+ if was_training:
133
+ model.train()
134
+
135
+ if total_tokens == 0:
136
+ return {
137
+ "candidate_nll": 0.0,
138
+ "candidate_perplexity": 1.0,
139
+ "top1_accuracy": 0.0,
140
+ "topk_accuracy": 0.0,
141
+ "candidate_coverage": 0.0,
142
+ "retrieval_recall": 0.0,
143
+ }
144
+
145
+ mean_nll = total_nll / total_tokens
146
+ return {
147
+ "candidate_nll": mean_nll,
148
+ "candidate_perplexity": math.exp(mean_nll),
149
+ "top1_accuracy": total_top1 / total_tokens,
150
+ "topk_accuracy": total_topk / total_tokens,
151
+ "candidate_coverage": total_coverage / total_tokens,
152
+ "retrieval_recall": total_retrieval / total_tokens,
153
+ }
154
+
155
+
156
+ @torch.no_grad()
157
+ def benchmark_attention_mode(
158
+ model: StructuredSparseEnergyTransformer,
159
+ input_ids: torch.Tensor,
160
+ attention_mode: str,
161
+ warmup: int,
162
+ runs: int,
163
+ ) -> dict[str, float]:
164
+ latencies_ms: list[float] = []
165
+ attention_density: list[float] = []
166
+ active_edges: list[float] = []
167
+ tokens_per_batch = float(input_ids.numel())
168
+
169
+ for _ in range(warmup):
170
+ model(input_ids, attention_mode=attention_mode)
171
+
172
+ for _ in range(runs):
173
+ start = time.perf_counter()
174
+ outputs = model(input_ids, attention_mode=attention_mode)
175
+ elapsed_ms = (time.perf_counter() - start) * 1000.0
176
+ latencies_ms.append(elapsed_ms)
177
+
178
+ nonzero = outputs["attention_weights"].gt(0).float()
179
+ attention_density.append(nonzero.mean().item())
180
+ active_edges.append(nonzero.sum(dim=-1).mean().item())
181
+
182
+ mean_latency_ms = sum(latencies_ms) / len(latencies_ms)
183
+ variance = sum((value - mean_latency_ms) ** 2 for value in latencies_ms) / len(latencies_ms)
184
+ return {
185
+ "latency_ms_mean": mean_latency_ms,
186
+ "latency_ms_std": math.sqrt(variance),
187
+ "tokens_per_second": tokens_per_batch / (mean_latency_ms / 1000.0),
188
+ "attention_density": sum(attention_density) / len(attention_density),
189
+ "attention_sparsity": 1.0 - (sum(attention_density) / len(attention_density)),
190
+ "active_edges_per_token": sum(active_edges) / len(active_edges),
191
+ }
192
+
193
+
194
+ WORD_PATTERN = re.compile(r"\w+|[^\w\s]", re.UNICODE)
195
+
196
+
197
+ def _surface_from_tokens(tokens: list[str]) -> str:
198
+ output: list[str] = []
199
+ for token in tokens:
200
+ if not output:
201
+ output.append(token)
202
+ continue
203
+ if re.match(r"^[^\w\s]+$", token):
204
+ output.append(token)
205
+ continue
206
+ if re.match(r"^[^\w\s]+$", output[-1]):
207
+ output.append(" ")
208
+ output.append(token)
209
+ continue
210
+ output.append(" ")
211
+ output.append(token)
212
+ return "".join(output).strip()
213
+
214
+
215
+ def sentence_to_prompt_completion(sentence: str) -> tuple[str, str]:
216
+ tokens = WORD_PATTERN.findall(sentence.strip())
217
+ if len(tokens) < 3:
218
+ raise ValueError(f"Sentence too short for completion evaluation: {sentence!r}")
219
+
220
+ split_index = len(tokens) - 1
221
+ if re.match(r"^[^\w\s]+$", tokens[-1]) and len(tokens) >= 3:
222
+ split_index = len(tokens) - 2
223
+
224
+ prompt_tokens = tokens[:split_index]
225
+ completion_tokens = tokens[split_index:]
226
+ return _surface_from_tokens(prompt_tokens), _surface_from_tokens(completion_tokens)
227
+
228
+
229
+ @torch.no_grad()
230
+ def evaluate_sentence_completions(
231
+ model: StructuredSparseEnergyTransformer,
232
+ tokenizer: Tokenizer,
233
+ sentences: list[str],
234
+ attention_mode: str | None = None,
235
+ ) -> dict[str, object]:
236
+ was_training = model.training
237
+ model.eval()
238
+
239
+ total_examples = 0
240
+ exact_matches = 0
241
+ token_matches = 0
242
+ total_completion_tokens = 0
243
+ examples: list[dict[str, str | bool]] = []
244
+
245
+ for sentence in sentences:
246
+ sentence = sentence.strip()
247
+ if not sentence:
248
+ continue
249
+
250
+ prompt_text, completion_text = sentence_to_prompt_completion(sentence)
251
+ prompt_ids = tokenizer.encode(prompt_text, add_bos=True)
252
+ target_ids = tokenizer.encode(completion_text, add_eos=True)
253
+ generated_ids = model.generate(
254
+ prompt_ids=prompt_ids,
255
+ eos_id=tokenizer.eos_id,
256
+ max_new_tokens=len(target_ids),
257
+ attention_mode=attention_mode,
258
+ )
259
+ predicted_completion_ids = generated_ids[len(prompt_ids) : len(prompt_ids) + len(target_ids)]
260
+ predicted_completion_text = tokenizer.decode(predicted_completion_ids)
261
+
262
+ exact_match = predicted_completion_ids == target_ids
263
+ exact_matches += int(exact_match)
264
+ total_examples += 1
265
+
266
+ common = min(len(predicted_completion_ids), len(target_ids))
267
+ token_matches += sum(
268
+ int(predicted_completion_ids[index] == target_ids[index])
269
+ for index in range(common)
270
+ )
271
+ total_completion_tokens += len(target_ids)
272
+
273
+ if len(examples) < 5:
274
+ examples.append(
275
+ {
276
+ "prompt": prompt_text,
277
+ "target_completion": completion_text,
278
+ "predicted_completion": predicted_completion_text,
279
+ "exact_match": exact_match,
280
+ }
281
+ )
282
+
283
+ if was_training:
284
+ model.train()
285
+
286
+ return {
287
+ "sentence_count": total_examples,
288
+ "exact_match_accuracy": exact_matches / max(1, total_examples),
289
+ "completion_token_accuracy": token_matches / max(1, total_completion_tokens),
290
+ "examples": examples,
291
+ }
bio_llm/training/recipes.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterable
5
+
6
+
7
+ WORD_PATTERN = re.compile(r"\w+|[^\w\s]", re.UNICODE)
8
+
9
+
10
+ CURRICULUM_PREFIXES: list[str] = [
11
+ "",
12
+ "in practice",
13
+ "for cpu tests",
14
+ "with sparse attention",
15
+ "during small runs",
16
+ "for quick checks",
17
+ "in this setup",
18
+ "on a tiny corpus",
19
+ "for compact models",
20
+ "under limited data",
21
+ "during fine tuning",
22
+ "for sentence completion",
23
+ "in the benchmark",
24
+ "for next word tests",
25
+ "with curriculum learning",
26
+ "on cpu",
27
+ "for retrieval tests",
28
+ "with energy scoring",
29
+ "for short prompts",
30
+ "during validation",
31
+ "for local windows",
32
+ "under sparse decoding",
33
+ "for repeated examples",
34
+ "with simple sentences",
35
+ ]
36
+
37
+
38
+ def _complexity_key(example: dict[str, str]) -> tuple[int, int, int, str]:
39
+ prompt_tokens = WORD_PATTERN.findall(example["input"].strip())
40
+ sentence_tokens = WORD_PATTERN.findall(example["sentence"].strip())
41
+ target_tokens = WORD_PATTERN.findall(example["next_word"].strip())
42
+ return (len(sentence_tokens), len(prompt_tokens), len(target_tokens), example["input"].strip().lower())
43
+
44
+
45
+ def augment_pair_examples(
46
+ examples: Iterable[dict[str, str]],
47
+ prefix_limit: int | None = None,
48
+ ) -> list[dict[str, str]]:
49
+ prefixes = CURRICULUM_PREFIXES if prefix_limit is None else CURRICULUM_PREFIXES[: max(1, prefix_limit)]
50
+ augmented: list[dict[str, str]] = []
51
+ seen: set[tuple[str, str, str]] = set()
52
+
53
+ for example in examples:
54
+ prompt = example["input"].strip()
55
+ next_word = example["next_word"].strip()
56
+ sentence = example["sentence"].strip()
57
+ for prefix in prefixes:
58
+ prefix_text = prefix.strip()
59
+ if prefix_text:
60
+ prompt_text = f"{prefix_text} {prompt}"
61
+ sentence_text = f"{prefix_text} {sentence}"
62
+ else:
63
+ prompt_text = prompt
64
+ sentence_text = sentence
65
+
66
+ key = (prompt_text.lower(), next_word.lower(), sentence_text.lower())
67
+ if key in seen:
68
+ continue
69
+ seen.add(key)
70
+ augmented.append(
71
+ {
72
+ "input": prompt_text,
73
+ "next_word": next_word,
74
+ "sentence": sentence_text,
75
+ }
76
+ )
77
+
78
+ return augmented
79
+
80
+
81
+ def order_examples_for_curriculum(examples: Iterable[dict[str, str]]) -> list[dict[str, str]]:
82
+ return sorted((dict(example) for example in examples), key=_complexity_key)
83
+
84
+
85
+ def build_training_text(
86
+ examples: Iterable[dict[str, str]],
87
+ repeat_factor: int = 1,
88
+ curriculum: bool = True,
89
+ ) -> str:
90
+ ordered_examples = order_examples_for_curriculum(examples) if curriculum else [dict(example) for example in examples]
91
+ sentences = [example["sentence"].strip() for example in ordered_examples if example["sentence"].strip()]
92
+ if not sentences:
93
+ raise ValueError("No sentences available to build the training corpus.")
94
+ repeated = sentences * max(1, repeat_factor)
95
+ return " ".join(repeated)
96
+
97
+
98
+ def split_phase_epochs(total_epochs: int) -> tuple[int, int, int]:
99
+ if total_epochs <= 1:
100
+ return max(1, total_epochs), 0, 0
101
+ if total_epochs == 2:
102
+ return 1, 1, 0
103
+
104
+ pretrain = max(1, round(total_epochs * 0.5))
105
+ freeze = max(1, round(total_epochs * 0.25))
106
+ finetune = total_epochs - pretrain - freeze
107
+
108
+ if finetune < 1:
109
+ finetune = 1
110
+ while pretrain + freeze + finetune > total_epochs and pretrain > 1:
111
+ pretrain -= 1
112
+ while pretrain + freeze + finetune > total_epochs and freeze > 1:
113
+ freeze -= 1
114
+ while pretrain + freeze + finetune > total_epochs and finetune > 1:
115
+ finetune -= 1
116
+ return pretrain, freeze, finetune
117
+
118
+
119
+ def _set_requires_grad(module, requires_grad: bool) -> None:
120
+ for parameter in module.parameters():
121
+ parameter.requires_grad = requires_grad
122
+
123
+
124
+ def freeze_backbone(model) -> None:
125
+ _set_requires_grad(model.embedding, False)
126
+ _set_requires_grad(model.sparse_attention, False)
127
+ _set_requires_grad(model.laminar, False)
128
+ _set_requires_grad(model.retriever, False)
129
+ _set_requires_grad(model.energy_head, True)
130
+
131
+
132
+ def unfreeze_all(model) -> None:
133
+ _set_requires_grad(model, True)
bio_llm/training/trainer.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ from dataclasses import replace
4
+ from pathlib import Path
5
+
6
+ import torch
7
+ from torch.utils.data import DataLoader, TensorDataset
8
+
9
+ from bio_llm.model.model import StructuredSparseEnergyTransformer
10
+ from bio_llm.training.loss import StructuredEnergyLoss
11
+ from bio_llm.training.metrics import evaluate_model
12
+ from bio_llm.utils.config import SSETConfig
13
+ from bio_llm.utils.tokenizer import BPETokenizer, Tokenizer, load_tokenizer
14
+
15
+
16
+ def set_seed(seed: int) -> None:
17
+ torch.manual_seed(seed)
18
+
19
+
20
+ def read_corpus(path: str | None) -> str:
21
+ if path is None:
22
+ base_corpus = (
23
+ "my name is sam . "
24
+ "i am ready . "
25
+ "we are happy . "
26
+ "the cat is small . "
27
+ "the sky is blue ."
28
+ )
29
+ return " ".join([base_corpus] * 4)
30
+ return Path(path).read_text(encoding="utf-8")
31
+
32
+
33
+ def make_language_model_dataset(token_ids: list[int], seq_len: int, pad_id: int) -> TensorDataset:
34
+ windows: list[list[int]] = []
35
+ stride = max(1, seq_len // 2)
36
+ if len(token_ids) <= seq_len:
37
+ padded = token_ids + [pad_id] * (seq_len + 1 - len(token_ids))
38
+ windows.append(padded[: seq_len + 1])
39
+ else:
40
+ for start in range(0, len(token_ids) - 1, stride):
41
+ chunk = token_ids[start : start + seq_len + 1]
42
+ if len(chunk) < 2:
43
+ continue
44
+ if len(chunk) < seq_len + 1:
45
+ chunk = chunk + [pad_id] * (seq_len + 1 - len(chunk))
46
+ windows.append(chunk)
47
+ if start + seq_len + 1 >= len(token_ids):
48
+ break
49
+
50
+ if not windows:
51
+ empty = torch.empty((0, seq_len + 1), dtype=torch.long)
52
+ return TensorDataset(empty[:, :-1], empty[:, 1:])
53
+
54
+ data = torch.tensor(windows, dtype=torch.long)
55
+ return TensorDataset(data[:, :-1], data[:, 1:])
56
+
57
+
58
+ def build_tokenizer_from_text(text: str, config: SSETConfig) -> Tokenizer:
59
+ return BPETokenizer.build(
60
+ [text],
61
+ vocab_size=config.tokenizer_vocab_size,
62
+ min_frequency=config.tokenizer_min_frequency,
63
+ )
64
+
65
+
66
+ def split_token_ids_for_holdout(
67
+ token_ids: list[int],
68
+ holdout_fraction: float,
69
+ ) -> tuple[list[int], list[int]]:
70
+ if len(token_ids) < 2 or holdout_fraction <= 0:
71
+ return token_ids, []
72
+
73
+ holdout_size = max(1, int(round(len(token_ids) * holdout_fraction)))
74
+ holdout_size = min(holdout_size, len(token_ids) - 1)
75
+ split_index = len(token_ids) - holdout_size
76
+ train_ids = token_ids[:split_index]
77
+ holdout_ids = token_ids[split_index:]
78
+ if not train_ids:
79
+ return token_ids, token_ids
80
+ return train_ids, holdout_ids
81
+
82
+
83
+ def fit_model(
84
+ model: StructuredSparseEnergyTransformer,
85
+ tokenizer: Tokenizer,
86
+ runtime_config: SSETConfig,
87
+ text: str,
88
+ evaluate_each_epoch: bool = True,
89
+ shuffle_train: bool = True,
90
+ verbose: bool = False,
91
+ log_interval: int = 1,
92
+ ) -> list[dict[str, float | int]]:
93
+ set_seed(runtime_config.seed)
94
+ model.config = runtime_config
95
+ token_ids = tokenizer.encode(text, add_bos=True, add_eos=True)
96
+ train_token_ids, holdout_token_ids = split_token_ids_for_holdout(token_ids, runtime_config.holdout_fraction)
97
+ train_dataset = make_language_model_dataset(train_token_ids, runtime_config.seq_len, tokenizer.pad_id)
98
+ holdout_dataset = make_language_model_dataset(holdout_token_ids, runtime_config.seq_len, tokenizer.pad_id)
99
+ if len(train_dataset) == 0:
100
+ raise ValueError("The training corpus is too short to build language-model windows.")
101
+
102
+ train_loader = DataLoader(train_dataset, batch_size=runtime_config.batch_size, shuffle=shuffle_train)
103
+ holdout_loader = DataLoader(holdout_dataset, batch_size=runtime_config.batch_size, shuffle=False)
104
+
105
+ trainable_parameters = [parameter for parameter in model.parameters() if parameter.requires_grad]
106
+ if not trainable_parameters:
107
+ raise ValueError("No trainable parameters are available for optimization.")
108
+
109
+ optimizer = torch.optim.Adam(trainable_parameters, lr=runtime_config.learning_rate)
110
+ loss_fn = StructuredEnergyLoss(
111
+ margin=runtime_config.margin,
112
+ margin_lambda=runtime_config.margin_lambda,
113
+ ignore_index=tokenizer.pad_id,
114
+ label_smoothing=runtime_config.label_smoothing,
115
+ hard_negative_count=runtime_config.hard_negative_count,
116
+ )
117
+
118
+ history: list[dict[str, float | int]] = []
119
+ model.train()
120
+ if verbose:
121
+ print("Training setup:")
122
+ print(f" total_tokens={len(token_ids)}")
123
+ print(f" train_tokens={len(train_token_ids)}")
124
+ print(f" holdout_tokens={len(holdout_token_ids)}")
125
+ print(f" train_windows={len(train_dataset)}")
126
+ print(f" holdout_windows={len(holdout_dataset)}")
127
+ print(f" batch_size={runtime_config.batch_size}")
128
+ print(f" epochs={runtime_config.epochs}")
129
+ print(f" learning_rate={runtime_config.learning_rate}")
130
+ print(f" attention_mode={runtime_config.attention_mode}")
131
+ for epoch in range(runtime_config.epochs):
132
+ epoch_wall_start = time.perf_counter()
133
+ epoch_cpu_start = time.process_time()
134
+ epoch_loss = 0.0
135
+ batches = 0
136
+ train_tokens = 0
137
+ train_examples = 0
138
+ batch_count = len(train_loader)
139
+ if verbose:
140
+ print(f"\n[train] epoch {epoch + 1}/{runtime_config.epochs} started")
141
+ for input_ids, target_ids in train_loader:
142
+ optimizer.zero_grad(set_to_none=True)
143
+ outputs = model(input_ids, target_ids=target_ids, attention_mode=runtime_config.attention_mode)
144
+ loss, stats = loss_fn(
145
+ energies=outputs["energies"],
146
+ log_probs=outputs["log_probs"],
147
+ candidate_ids=outputs["candidate_ids"],
148
+ target_ids=target_ids,
149
+ )
150
+ loss.backward()
151
+ torch.nn.utils.clip_grad_norm_(model.parameters(), runtime_config.grad_clip)
152
+ optimizer.step()
153
+ epoch_loss += stats["loss"]
154
+ batches += 1
155
+ train_tokens += int(input_ids.numel())
156
+ train_examples += int(input_ids.size(0))
157
+ if verbose and (batches % max(1, log_interval) == 0 or batches == batch_count):
158
+ average_loss = epoch_loss / max(1, batches)
159
+ print(
160
+ f"[train] epoch {epoch + 1}/{runtime_config.epochs} "
161
+ f"batch {batches}/{batch_count} "
162
+ f"batch_loss={stats['loss']:.4f} avg_loss={average_loss:.4f} "
163
+ f"examples_seen={train_examples}"
164
+ )
165
+
166
+ train_seconds = time.perf_counter() - epoch_wall_start
167
+ train_cpu_seconds = time.process_time() - epoch_cpu_start
168
+
169
+ epoch_record: dict[str, float | int] = {
170
+ "epoch": epoch + 1,
171
+ "loss": epoch_loss / max(1, batches),
172
+ "train_seconds": train_seconds,
173
+ "train_cpu_seconds": train_cpu_seconds,
174
+ "train_tokens": train_tokens,
175
+ "train_examples": train_examples,
176
+ "train_tokens_per_second": train_tokens / max(train_seconds, 1e-12),
177
+ "train_examples_per_second": train_examples / max(train_seconds, 1e-12),
178
+ "train_cpu_to_wall_ratio": train_cpu_seconds / max(train_seconds, 1e-12),
179
+ "holdout_tokens": len(holdout_token_ids),
180
+ "holdout_examples": len(holdout_dataset),
181
+ }
182
+ if evaluate_each_epoch:
183
+ if verbose:
184
+ print(f"[eval] epoch {epoch + 1}/{runtime_config.epochs} holdout evaluation started")
185
+ eval_wall_start = time.perf_counter()
186
+ epoch_metrics = evaluate_model(
187
+ model=model,
188
+ loader=holdout_loader,
189
+ ignore_index=tokenizer.pad_id,
190
+ attention_mode=runtime_config.attention_mode,
191
+ top_k=min(3, runtime_config.retrieval_stage2_k),
192
+ )
193
+ epoch_record.update({f"validation_{key}": value for key, value in epoch_metrics.items()})
194
+ epoch_record["evaluation_seconds"] = time.perf_counter() - eval_wall_start
195
+ if verbose:
196
+ print(
197
+ f"[eval] epoch {epoch + 1}/{runtime_config.epochs} "
198
+ f"candidate_perplexity={epoch_metrics['candidate_perplexity']:.4f} "
199
+ f"top1_accuracy={epoch_metrics['top1_accuracy']:.4f} "
200
+ f"topk_accuracy={epoch_metrics['topk_accuracy']:.4f} "
201
+ f"retrieval_recall={epoch_metrics['retrieval_recall']:.4f}"
202
+ )
203
+ if verbose:
204
+ print(
205
+ f"[train] epoch {epoch + 1}/{runtime_config.epochs} completed "
206
+ f"loss={epoch_record['loss']:.4f} "
207
+ f"train_seconds={train_seconds:.2f}"
208
+ )
209
+ history.append(epoch_record)
210
+
211
+ model.eval()
212
+ return history
213
+
214
+
215
+ def save_checkpoint(
216
+ model: StructuredSparseEnergyTransformer,
217
+ tokenizer: Tokenizer,
218
+ output_dir: str | Path,
219
+ history: list[dict[str, float | int]] | None = None,
220
+ ) -> Path:
221
+ output_path = Path(output_dir)
222
+ output_path.mkdir(parents=True, exist_ok=True)
223
+ checkpoint_path = output_path / "sset.pt"
224
+ torch.save({"config": model.config.to_dict(), "state_dict": model.state_dict()}, checkpoint_path)
225
+ tokenizer.save(output_path / "tokenizer.json")
226
+ if history is not None:
227
+ (output_path / "history.json").write_text(json.dumps(history, indent=2), encoding="utf-8")
228
+ return checkpoint_path
229
+
230
+
231
+ def _load_state_dict_with_resize(
232
+ model: StructuredSparseEnergyTransformer,
233
+ state_dict: dict[str, torch.Tensor],
234
+ ) -> None:
235
+ current_state = model.state_dict()
236
+ merged_state: dict[str, torch.Tensor] = dict(current_state)
237
+
238
+ for name, source in state_dict.items():
239
+ if name not in current_state:
240
+ continue
241
+
242
+ target = current_state[name]
243
+ if source.shape == target.shape:
244
+ merged_state[name] = source
245
+ continue
246
+
247
+ if source.ndim == target.ndim and source.ndim > 0:
248
+ if source.shape[:-1] == target.shape[:-1] and source.shape[-1] <= target.shape[-1]:
249
+ resized = target.clone()
250
+ resized[..., : source.shape[-1]] = source
251
+ merged_state[name] = resized
252
+ continue
253
+
254
+ if source.shape[1:] == target.shape[1:] and source.shape[0] <= target.shape[0]:
255
+ resized = target.clone()
256
+ resized[: source.shape[0]] = source
257
+ merged_state[name] = resized
258
+ continue
259
+
260
+ model.load_state_dict(merged_state)
261
+
262
+
263
+ def load_checkpoint(
264
+ checkpoint_path: str | Path,
265
+ tokenizer_path: str | Path | None = None,
266
+ config_overrides: dict[str, object] | None = None,
267
+ ) -> tuple[StructuredSparseEnergyTransformer, Tokenizer]:
268
+ checkpoint = torch.load(checkpoint_path, map_location="cpu")
269
+ config = SSETConfig(**checkpoint["config"])
270
+ if config_overrides:
271
+ overrides = dict(config_overrides)
272
+ if "max_seq_len" in overrides and "seq_len" not in overrides:
273
+ overrides["seq_len"] = overrides["max_seq_len"]
274
+ config = replace(config, **overrides)
275
+ model = StructuredSparseEnergyTransformer(config)
276
+ _load_state_dict_with_resize(model, checkpoint["state_dict"])
277
+ model.eval()
278
+ resolved_tokenizer_path = (
279
+ Path(tokenizer_path) if tokenizer_path is not None else Path(checkpoint_path).with_name("tokenizer.json")
280
+ )
281
+ tokenizer = load_tokenizer(resolved_tokenizer_path)
282
+ return model, tokenizer
283
+
284
+
285
+ def train_model(
286
+ config: SSETConfig,
287
+ text: str,
288
+ tokenizer: Tokenizer | None = None,
289
+ evaluate_each_epoch: bool = True,
290
+ shuffle_train: bool = True,
291
+ verbose: bool = False,
292
+ log_interval: int = 1,
293
+ ) -> tuple[StructuredSparseEnergyTransformer, Tokenizer, list[dict[str, float | int]]]:
294
+ set_seed(config.seed)
295
+ tokenizer = tokenizer or build_tokenizer_from_text(text, config)
296
+ runtime_config = replace(config, vocab_size=tokenizer.vocab_size)
297
+ model = StructuredSparseEnergyTransformer(runtime_config)
298
+ history = fit_model(
299
+ model=model,
300
+ tokenizer=tokenizer,
301
+ runtime_config=runtime_config,
302
+ text=text,
303
+ evaluate_each_epoch=evaluate_each_epoch,
304
+ shuffle_train=shuffle_train,
305
+ verbose=verbose,
306
+ log_interval=log_interval,
307
+ )
308
+ return model, tokenizer, history
bio_llm/utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Utility helpers for the Structured Sparse Energy Transformer."""
bio_llm/utils/config.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import asdict, dataclass
2
+
3
+
4
+ @dataclass
5
+ class SSETConfig:
6
+ vocab_size: int = 0
7
+ d_model: int = 128
8
+ low_rank: int = 16
9
+ stage1_dim: int = 32
10
+ max_seq_len: int = 32
11
+ attention_top_k: int = 6
12
+ local_window: int = 12
13
+ memory_candidates: int = 6
14
+ landmark_count: int = 4
15
+ content_memory_candidates: int = 4
16
+ retrieval_stage1_k: int = 48
17
+ retrieval_stage2_k: int = 12
18
+ laminar_steps: int = 2
19
+ laminar_eta: float = 0.1
20
+ transition_rank: int = 32
21
+ attention_mode: str = "sparsemax"
22
+ learning_rate: float = 3e-3
23
+ batch_size: int = 8
24
+ epochs: int = 8
25
+ seq_len: int = 32
26
+ holdout_fraction: float = 0.1
27
+ margin: float = 0.25
28
+ margin_lambda: float = 0.2
29
+ label_smoothing: float = 0.05
30
+ hard_negative_count: int = 2
31
+ grad_clip: float = 1.0
32
+ seed: int = 7
33
+ tokenizer_vocab_size: int = 256
34
+ tokenizer_min_frequency: int = 2
35
+
36
+ def to_dict(self) -> dict:
37
+ return asdict(self)
bio_llm/utils/export.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ from bio_llm.training.trainer import load_checkpoint
9
+
10
+
11
+ def export_checkpoint_to_npz(
12
+ checkpoint_path: str | Path,
13
+ output_path: str | Path,
14
+ tokenizer_path: str | Path | None = None,
15
+ max_seq_len: int | None = None,
16
+ ) -> Path:
17
+ config_overrides = {"max_seq_len": max_seq_len} if max_seq_len is not None else None
18
+ model, tokenizer = load_checkpoint(
19
+ checkpoint_path=checkpoint_path,
20
+ tokenizer_path=tokenizer_path,
21
+ config_overrides=config_overrides,
22
+ )
23
+
24
+ arrays: dict[str, np.ndarray] = {}
25
+ parameter_names: list[str] = []
26
+ for name, tensor in model.state_dict().items():
27
+ safe_name = name.replace(".", "__")
28
+ arrays[safe_name] = tensor.detach().cpu().numpy()
29
+ parameter_names.append(name)
30
+
31
+ arrays["__parameter_names__"] = np.array(parameter_names, dtype=object)
32
+ arrays["__config_json__"] = np.array(json.dumps(model.config.to_dict(), ensure_ascii=True))
33
+ arrays["__tokenizer_json__"] = np.array(
34
+ json.dumps(
35
+ {
36
+ "type": "bpe" if hasattr(tokenizer, "merges") else "simple",
37
+ "vocab": tokenizer.id_to_token,
38
+ "merges": [list(pair) for pair in getattr(tokenizer, "merges", [])],
39
+ },
40
+ ensure_ascii=True,
41
+ )
42
+ )
43
+
44
+ resolved_output = Path(output_path)
45
+ resolved_output.parent.mkdir(parents=True, exist_ok=True)
46
+ np.savez_compressed(resolved_output, **arrays)
47
+ return resolved_output
bio_llm/utils/tokenizer.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from collections import Counter
4
+ from pathlib import Path
5
+ from typing import Iterable, List, Sequence
6
+
7
+
8
+ class SimpleTokenizer:
9
+ """A small word-and-punctuation tokenizer for CPU-only experiments."""
10
+
11
+ PAD = "<pad>"
12
+ BOS = "<bos>"
13
+ EOS = "<eos>"
14
+ UNK = "<unk>"
15
+ TOKEN_PATTERN = re.compile(r"\w+|[^\w\s]", re.UNICODE)
16
+
17
+ def __init__(self, vocab: List[str]):
18
+ self.id_to_token = vocab
19
+ self.token_to_id = {token: index for index, token in enumerate(vocab)}
20
+
21
+ @classmethod
22
+ def build(cls, texts: Iterable[str], min_freq: int = 1) -> "SimpleTokenizer":
23
+ counter: Counter[str] = Counter()
24
+ for text in texts:
25
+ counter.update(cls.tokenize(text))
26
+
27
+ vocab = [cls.PAD, cls.BOS, cls.EOS, cls.UNK]
28
+ for token, freq in counter.most_common():
29
+ if freq >= min_freq and token not in vocab:
30
+ vocab.append(token)
31
+ return cls(vocab)
32
+
33
+ @staticmethod
34
+ def tokenize(text: str) -> List[str]:
35
+ return SimpleTokenizer.TOKEN_PATTERN.findall(text)
36
+
37
+ @property
38
+ def vocab_size(self) -> int:
39
+ return len(self.id_to_token)
40
+
41
+ @property
42
+ def pad_id(self) -> int:
43
+ return self.token_to_id[self.PAD]
44
+
45
+ @property
46
+ def bos_id(self) -> int:
47
+ return self.token_to_id[self.BOS]
48
+
49
+ @property
50
+ def eos_id(self) -> int:
51
+ return self.token_to_id[self.EOS]
52
+
53
+ @property
54
+ def unk_id(self) -> int:
55
+ return self.token_to_id[self.UNK]
56
+
57
+ def encode(self, text: str, add_bos: bool = False, add_eos: bool = False) -> List[int]:
58
+ tokens = self.tokenize(text)
59
+ ids = [self.token_to_id.get(token, self.unk_id) for token in tokens]
60
+ if add_bos:
61
+ ids.insert(0, self.bos_id)
62
+ if add_eos:
63
+ ids.append(self.eos_id)
64
+ return ids
65
+
66
+ def decode(self, token_ids: Iterable[int], skip_special_tokens: bool = True) -> str:
67
+ tokens: List[str] = []
68
+ specials = {self.PAD, self.BOS, self.EOS, self.UNK}
69
+ for token_id in token_ids:
70
+ token = self.id_to_token[int(token_id)]
71
+ if skip_special_tokens and token in specials:
72
+ continue
73
+ tokens.append(token)
74
+
75
+ output = []
76
+ for token in tokens:
77
+ if output and re.match(r"\w", token) and re.match(r"\w", output[-1][-1]):
78
+ output.append(" ")
79
+ elif output and token not in {".", ",", "!", "?", ":", ";", "'", '"', ")"} and output[-1] not in {"(", '"'}:
80
+ output.append(" ")
81
+ output.append(token)
82
+ return "".join(output).strip()
83
+
84
+ def save(self, path: str | Path) -> None:
85
+ payload = {"vocab": self.id_to_token}
86
+ Path(path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
87
+
88
+ @classmethod
89
+ def load(cls, path: str | Path) -> "SimpleTokenizer":
90
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
91
+ return cls(payload["vocab"])
92
+
93
+
94
+ class BPETokenizer:
95
+ """A compact BPE tokenizer with greedy longest-match encoding."""
96
+
97
+ PAD = "<pad>"
98
+ BOS = "<bos>"
99
+ EOS = "<eos>"
100
+ UNK = "<unk>"
101
+ END_OF_WORD = "</w>"
102
+ TOKEN_PATTERN = re.compile(r"\w+|[^\w\s]", re.UNICODE)
103
+
104
+ def __init__(self, vocab: Sequence[str], merges: Sequence[list[str] | tuple[str, str]]):
105
+ self.id_to_token = list(vocab)
106
+ self.token_to_id = {token: index for index, token in enumerate(self.id_to_token)}
107
+ self.merges = [tuple(pair) for pair in merges]
108
+ self.merge_ranks = {pair: index for index, pair in enumerate(self.merges)}
109
+
110
+ @classmethod
111
+ def build(
112
+ cls,
113
+ texts: Iterable[str],
114
+ vocab_size: int = 256,
115
+ min_frequency: int = 2,
116
+ ) -> "BPETokenizer":
117
+ words = Counter()
118
+ for text in texts:
119
+ words.update(cls.TOKEN_PATTERN.findall(text))
120
+
121
+ word_pieces = {
122
+ word: tuple(list(word) + [cls.END_OF_WORD])
123
+ for word, frequency in words.items()
124
+ if frequency >= 1
125
+ }
126
+ merges: list[tuple[str, str]] = []
127
+ special_tokens = [cls.PAD, cls.BOS, cls.EOS, cls.UNK]
128
+ symbol_vocab = {symbol for pieces in word_pieces.values() for symbol in pieces}
129
+
130
+ while len(symbol_vocab) + len(special_tokens) < vocab_size:
131
+ pair_counts: Counter[tuple[str, str]] = Counter()
132
+ for word, pieces in word_pieces.items():
133
+ frequency = words[word]
134
+ for index in range(len(pieces) - 1):
135
+ pair_counts[(pieces[index], pieces[index + 1])] += frequency
136
+
137
+ if not pair_counts:
138
+ break
139
+
140
+ best_pair, best_frequency = pair_counts.most_common(1)[0]
141
+ if best_frequency < min_frequency:
142
+ break
143
+
144
+ merged_symbol = "".join(best_pair)
145
+ merges.append(best_pair)
146
+ updated: dict[str, tuple[str, ...]] = {}
147
+ for word, pieces in word_pieces.items():
148
+ new_pieces: list[str] = []
149
+ index = 0
150
+ while index < len(pieces):
151
+ if index < len(pieces) - 1 and (pieces[index], pieces[index + 1]) == best_pair:
152
+ new_pieces.append(merged_symbol)
153
+ index += 2
154
+ else:
155
+ new_pieces.append(pieces[index])
156
+ index += 1
157
+ updated[word] = tuple(new_pieces)
158
+ word_pieces = updated
159
+ symbol_vocab = {symbol for pieces in word_pieces.values() for symbol in pieces}
160
+
161
+ vocab = special_tokens + sorted(symbol_vocab)
162
+ return cls(vocab=vocab, merges=merges)
163
+
164
+ @staticmethod
165
+ def tokenize(text: str) -> List[str]:
166
+ return BPETokenizer.TOKEN_PATTERN.findall(text)
167
+
168
+ @property
169
+ def vocab_size(self) -> int:
170
+ return len(self.id_to_token)
171
+
172
+ @property
173
+ def pad_id(self) -> int:
174
+ return self.token_to_id[self.PAD]
175
+
176
+ @property
177
+ def bos_id(self) -> int:
178
+ return self.token_to_id[self.BOS]
179
+
180
+ @property
181
+ def eos_id(self) -> int:
182
+ return self.token_to_id[self.EOS]
183
+
184
+ @property
185
+ def unk_id(self) -> int:
186
+ return self.token_to_id[self.UNK]
187
+
188
+ def _apply_merges(self, word: str) -> list[str]:
189
+ pieces = list(word) + [self.END_OF_WORD]
190
+ if len(pieces) == 1:
191
+ return pieces
192
+
193
+ while True:
194
+ candidates = []
195
+ for index in range(len(pieces) - 1):
196
+ pair = (pieces[index], pieces[index + 1])
197
+ if pair in self.merge_ranks:
198
+ candidates.append((self.merge_ranks[pair], index, pair))
199
+ if not candidates:
200
+ break
201
+
202
+ _, merge_index, pair = min(candidates)
203
+ pieces = pieces[:merge_index] + ["".join(pair)] + pieces[merge_index + 2 :]
204
+ return pieces
205
+
206
+ def encode(self, text: str, add_bos: bool = False, add_eos: bool = False) -> List[int]:
207
+ ids: list[int] = []
208
+ if add_bos:
209
+ ids.append(self.bos_id)
210
+ for token in self.tokenize(text):
211
+ if re.match(r"\w+", token):
212
+ pieces = self._apply_merges(token)
213
+ else:
214
+ pieces = [token + self.END_OF_WORD]
215
+ if pieces[0] not in self.token_to_id:
216
+ pieces = [token, self.END_OF_WORD]
217
+ for piece in pieces:
218
+ ids.append(self.token_to_id.get(piece, self.unk_id))
219
+ if add_eos:
220
+ ids.append(self.eos_id)
221
+ return ids
222
+
223
+ def decode(self, token_ids: Iterable[int], skip_special_tokens: bool = True) -> str:
224
+ specials = {self.PAD, self.BOS, self.EOS, self.UNK}
225
+ words: list[str] = []
226
+ current = ""
227
+ for token_id in token_ids:
228
+ token = self.id_to_token[int(token_id)]
229
+ if skip_special_tokens and token in specials:
230
+ continue
231
+ if token == self.END_OF_WORD:
232
+ if current:
233
+ words.append(current)
234
+ current = ""
235
+ continue
236
+ if token.endswith(self.END_OF_WORD):
237
+ current += token[: -len(self.END_OF_WORD)]
238
+ words.append(current)
239
+ current = ""
240
+ else:
241
+ current += token
242
+
243
+ if current:
244
+ words.append(current)
245
+
246
+ output: list[str] = []
247
+ for word in words:
248
+ if not output:
249
+ output.append(word)
250
+ elif re.match(r"^[^\w\s]+$", word):
251
+ output.append(word)
252
+ elif re.match(r"^[^\w\s]+$", output[-1]):
253
+ output.append(" ")
254
+ output.append(word)
255
+ else:
256
+ output.append(" ")
257
+ output.append(word)
258
+ return "".join(output).replace(" ", " ").strip()
259
+
260
+ def save(self, path: str | Path) -> None:
261
+ payload = {"type": "bpe", "vocab": self.id_to_token, "merges": [list(pair) for pair in self.merges]}
262
+ Path(path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
263
+
264
+ @classmethod
265
+ def load(cls, path: str | Path) -> "BPETokenizer":
266
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
267
+ return cls(payload["vocab"], payload.get("merges", []))
268
+
269
+
270
+ Tokenizer = SimpleTokenizer | BPETokenizer
271
+
272
+
273
+ def load_tokenizer(path: str | Path) -> Tokenizer:
274
+ payload = json.loads(Path(path).read_text(encoding="utf-8"))
275
+ if payload.get("type") == "bpe":
276
+ return BPETokenizer(payload["vocab"], payload.get("merges", []))
277
+ return SimpleTokenizer(payload["vocab"])
bio_voice_tts/TRAINING_INFRASTRUCTURE.md ADDED
@@ -0,0 +1,964 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BioVoice-TTS Training Infrastructure
2
+ ## Sparse Energy-Based Voice Cloning Foundation Model
3
+
4
+ This document describes the complete training infrastructure, data pipeline, code blueprint, and staged systems plan for `BioVoice-TTS`, a CPU-first sparse voice cloning foundation model built around:
5
+
6
+ - low-rank QKV projections
7
+ - sparse temporal attention
8
+ - laminar refinement
9
+ - energy-based decoding
10
+ - multi-scale memory
11
+ - FiLM speaker conditioning
12
+ - sparse acoustic decoding
13
+ - lightweight sparse neural vocoding
14
+ - streaming-first inference and training
15
+
16
+ The code accompanying this document lives under `bio_voice_tts/` and is organized as a modular PyTorch training stack with production-oriented boundaries.
17
+
18
+ ---
19
+
20
+ # 1. Complete Repository Structure
21
+
22
+ ```text
23
+ bio_voice_tts/
24
+ ├── __init__.py
25
+ ├── TRAINING_INFRASTRUCTURE.md
26
+ ├── audio/
27
+ │ ├── __init__.py
28
+ │ ├── stft.py
29
+ │ ├── mel.py
30
+ │ ├── phoneme.py
31
+ │ └── features.py
32
+ ├── benchmarks/
33
+ │ ├── __init__.py
34
+ │ └── benchmark_cpu.py
35
+ ├── configs/
36
+ │ ├── base.yaml
37
+ │ ├── speaker_encoder.yaml
38
+ │ ├── acoustic_decoder.yaml
39
+ │ ├── vocoder.yaml
40
+ │ └── datasets.yaml
41
+ ├── datasets/
42
+ │ ├── __init__.py
43
+ │ ├── manifest.py
44
+ │ ├── speaker_dataset.py
45
+ │ └── tts_dataset.py
46
+ ├── evaluation/
47
+ │ ├── __init__.py
48
+ │ ├── metrics.py
49
+ │ └── evaluate.py
50
+ ├── inference/
51
+ │ ├── __init__.py
52
+ │ ├── synthesize.py
53
+ │ ├── clone_voice.py
54
+ │ └── realtime_stream.py
55
+ ├── model/
56
+ │ ├── __init__.py
57
+ │ ├── low_rank_qkv.py
58
+ │ ├── sparse_attention.py
59
+ │ ├── laminar.py
60
+ │ ├── memory.py
61
+ │ ├── speaker_encoder.py
62
+ │ ├── semantic_encoder.py
63
+ │ ├── prosody.py
64
+ │ ├── acoustic_decoder.py
65
+ │ ├── mel_generator.py
66
+ │ └── biovoice_tts.py
67
+ ├── preprocessing/
68
+ │ ├── __init__.py
69
+ │ ├── preprocessing.py
70
+ │ ├── stft.py
71
+ │ ├── mel.py
72
+ │ └── phoneme.py
73
+ ├── scripts/
74
+ │ ├── prepare_manifest.py
75
+ │ ├── train_speaker.py
76
+ │ ├── train_tts.py
77
+ │ └── train_vocoder.py
78
+ ├── streaming/
79
+ │ ├── __init__.py
80
+ │ ├── cache.py
81
+ │ └── realtime_stream.py
82
+ ├── training/
83
+ │ ├── __init__.py
84
+ │ ├── losses.py
85
+ │ ├── checkpoint.py
86
+ │ ├── distributed.py
87
+ │ ├── trainer.py
88
+ │ ├── speaker_trainer.py
89
+ │ ├── acoustic_trainer.py
90
+ │ └── vocoder_trainer.py
91
+ ├── utils/
92
+ │ ├── __init__.py
93
+ │ ├── config.py
94
+ │ ├── logging.py
95
+ │ ├── seed.py
96
+ │ └── device.py
97
+ └── vocoder/
98
+ ├── __init__.py
99
+ ├── sparse_vocoder.py
100
+ └── discriminator.py
101
+ ```
102
+
103
+ ## Module Roles
104
+
105
+ `audio/`
106
+
107
+ - Implements mathematically explicit STFT, mel projection, text normalization, fallback phonemization, and CPU-friendly feature extraction.
108
+ - `features.py` is the main acoustic preprocessing engine used by training, evaluation, and inference.
109
+
110
+ `datasets/`
111
+
112
+ - Standardizes JSONL manifests into `ManifestEntry` records.
113
+ - Splits data loading into speaker-centric triplet sampling and TTS-centric token/mel loading.
114
+ - Keeps batching memory-safe by padding only to batch-local maxima.
115
+
116
+ `preprocessing/`
117
+
118
+ - Houses the manifest preprocessing pipeline and file-level wrappers around signal processing modules.
119
+ - Intended for offline feature extraction so CPU training spends less time in repeated IO and FFT work.
120
+
121
+ `model/`
122
+
123
+ - Contains the sparse semantic core, speaker encoder, prosody heads, acoustic decoder, and full assembly model.
124
+ - Preserves the original SSET principles by keeping low-rank, sparse, and laminar modules isolated and reusable.
125
+
126
+ `training/`
127
+
128
+ - Contains reusable losses, checkpoint management, optimizer scheduling, distributed setup, and specialized trainers.
129
+ - Supports staged training: speaker encoder first, then text-to-mel, then vocoder, then joint refinement.
130
+
131
+ `vocoder/`
132
+
133
+ - Implements a lightweight causal sparse vocoder plus a compact waveform discriminator for adversarial refinement.
134
+
135
+ `streaming/`
136
+
137
+ - Encapsulates cache management and chunk-wise synthesis, so streaming logic does not pollute core model code.
138
+
139
+ `inference/`
140
+
141
+ - Exposes offline cloning and realtime-style chunked synthesis entrypoints.
142
+
143
+ `evaluation/`
144
+
145
+ - Measures mel error, pseudo-MOS, and speaker consistency proxies.
146
+ - Intended to be expanded with ASR-backed WER, speaker verification EER, and MOSNet-like learned estimators.
147
+
148
+ `benchmarks/`
149
+
150
+ - Tracks mean CPU latency, peak memory, and mel throughput.
151
+ - Used to compare sparse vs dense variants under fixed input sizes.
152
+
153
+ `configs/`
154
+
155
+ - YAML-driven configuration surface for datasets, speaker encoder, text-to-mel, and vocoder recipes.
156
+
157
+ `scripts/`
158
+
159
+ - Command-line entrypoints for preprocessing and staged training.
160
+
161
+ ---
162
+
163
+ # 2. Data Sources
164
+
165
+ The datasets below are the best starting points for a CPU-first voice cloning foundation model. Sizes and speaker counts are approximate and should be verified against the exact release you download.
166
+
167
+ ## Recommended Dataset Roles
168
+
169
+ - Best for speaker encoder:
170
+ - VoxCeleb
171
+ - VCTK
172
+ - MLS
173
+ - Common Voice
174
+ - Best for single-speaker clean TTS bootstrapping:
175
+ - LJSpeech
176
+ - Best for multi-speaker TTS:
177
+ - LibriTTS
178
+ - VCTK
179
+ - MLS
180
+ - Best for multilingual synthesis:
181
+ - MLS
182
+ - Common Voice
183
+ - AISHELL for Mandarin
184
+ - Hindi subsets from Common Voice and Indic speech resources
185
+ - Best for streaming speech and future duplex STT/TTS:
186
+ - GigaSpeech
187
+ - Fisher English
188
+ - Common Voice
189
+
190
+ ## Dataset Table
191
+
192
+ | Dataset | Primary Use | Approx. Size | Speakers | Typical SR | Transcript Format | License / Access | Official Link |
193
+ |---|---|---:|---:|---:|---|---|---|
194
+ | LibriTTS | Clean multi-speaker TTS | ~585 hours | ~2.4k | 24 kHz | aligned text / normalized text | CC BY 4.0 | https://openslr.org/60/ |
195
+ | VCTK | Multi-speaker cloning / accents | ~44 hours | 100+ | 48 kHz | per-utterance text | Edinburgh DataShare terms | https://datashare.ed.ac.uk/handle/10283/3443 |
196
+ | LJSpeech | Single-speaker TTS bootstrap | ~24 hours | 1 | 22.05 kHz | metadata CSV | Public domain derivatives / project terms | https://keithito.com/LJ-Speech-Dataset/ |
197
+ | Common Voice | Multilingual speech | release-dependent, large | very large | 48 kHz source | TSV/CSV + clips | CC0 | https://commonvoice.mozilla.org/ |
198
+ | VoxCeleb | Speaker encoder pretraining | >2k hours | 7k+ | 16 kHz common recipes | speaker ids + metadata | research access, verify terms | https://www.robots.ox.ac.uk/~vgg/data/voxceleb/ |
199
+ | AISHELL-1 | Mandarin ASR / multilingual speech | ~178 hours | 400 | 16 kHz | Kaldi-style transcripts | Apache 2.0 | https://openslr.org/33/ |
200
+ | MLS | Multilingual LibriSpeech-style corpus | tens of thousands of hours across languages | many thousands | 16 kHz | transcript files | CC BY 4.0 | https://openslr.org/94/ |
201
+ | GigaSpeech | large-scale speech + transcripts | 10k+ hours by subset | many | 16 kHz | JSON / segment metadata | see repo license and corpus terms | https://github.com/SpeechColab/GigaSpeech |
202
+ | Fisher English | conversational speech / streaming STT | ~2k hours | many thousands | 8 kHz | LDC transcripts | LDC licensed | https://catalog.ldc.upenn.edu/LDC2004T19 |
203
+ | Hindi: Common Voice Hindi | multilingual Hindi speech | release-dependent | many | 48 kHz source | TSV/CSV | CC0 | https://commonvoice.mozilla.org/ |
204
+ | Hindi: IndicVoices / AI4Bharat resources | Indian language speech | release-dependent | large | varies | manifest / JSON / TSV | verify individual dataset license | https://ai4bharat.iitm.ac.in/ |
205
+
206
+ ## Dataset Preparation Strategy
207
+
208
+ 1. Convert all audio to a canonical sample rate:
209
+ - `24000` Hz for TTS
210
+ - `16000` Hz auxiliary branch for speaker verification if needed
211
+ 2. Normalize transcripts:
212
+ - lowercase
213
+ - Unicode normalization
214
+ - punctuation pruning
215
+ - numeric expansion where appropriate
216
+ 3. Build a canonical JSONL manifest.
217
+ 4. Precompute mel, log-energy, and pitch features.
218
+ 5. Store speaker-balanced splits.
219
+
220
+ ## Download and Preparation Scripts
221
+
222
+ The codebase expects a JSONL manifest. Example workflow:
223
+
224
+ ```bash
225
+ python -m bio_voice_tts.scripts.prepare_manifest \
226
+ --manifest data/train_manifest.jsonl \
227
+ --feature-dir data/features \
228
+ --sample-rate 24000 \
229
+ --n-mels 80
230
+ ```
231
+
232
+ For large sources:
233
+
234
+ - keep original archives in `data/raw/`
235
+ - export normalized manifests into `data/manifests/`
236
+ - store precomputed features in `data/features/<speaker_id>/`
237
+
238
+ ---
239
+
240
+ # 3. Dataset Format
241
+
242
+ ## Canonical Layout
243
+
244
+ ```text
245
+ datasets/
246
+ ├── speaker_001/
247
+ │ ├── audio/
248
+ │ │ ├── utt_0001.wav
249
+ │ │ └── utt_0002.wav
250
+ │ ├── transcript.txt
251
+ │ └── metadata.json
252
+ └── manifests/
253
+ ├── train_manifest.jsonl
254
+ └── eval_manifest.jsonl
255
+ ```
256
+
257
+ ## `metadata.json`
258
+
259
+ ```json
260
+ {
261
+ "speaker_id": "001",
262
+ "gender": "female",
263
+ "language": "en",
264
+ "sample_rate": 22050
265
+ }
266
+ ```
267
+
268
+ ## JSONL Manifest Schema
269
+
270
+ ```json
271
+ {"audio_path":"datasets/speaker_001/audio/utt_0001.wav","text":"welcome to the interview","speaker_id":"001","language":"en","sample_rate":24000}
272
+ {"audio_path":"datasets/speaker_001/audio/utt_0002.wav","text":"thank you for joining us","speaker_id":"001","language":"en","sample_rate":24000}
273
+ ```
274
+
275
+ ## Normalization Rules
276
+
277
+ - transcript normalization:
278
+ - lowercase
279
+ - whitespace collapse
280
+ - remove unsupported symbols
281
+ - punctuation cleaning:
282
+ - keep sentence boundaries and pauses when useful
283
+ - phoneme conversion:
284
+ - language-specific G2P if available
285
+ - fallback grapheme-to-token path for fast experiments
286
+ - silence trimming:
287
+ - trim leading/trailing silence with energy threshold or VAD
288
+ - audio normalization:
289
+ - peak or RMS normalization
290
+ - mel extraction:
291
+ - fixed FFT/hop/window parameters per experiment family
292
+
293
+ ---
294
+
295
+ # 4. Audio Preprocessing
296
+
297
+ The code files are:
298
+
299
+ - `audio/stft.py`
300
+ - `audio/mel.py`
301
+ - `audio/phoneme.py`
302
+ - `audio/features.py`
303
+ - `preprocessing/preprocessing.py`
304
+
305
+ ## Mathematics
306
+
307
+ STFT:
308
+
309
+ \[
310
+ \text{STFT}(m,\omega)=\sum_{n=0}^{N-1}x[n]w[n-mH]e^{-j\omega n}
311
+ \]
312
+
313
+ Power spectrogram:
314
+
315
+ \[
316
+ P(m,\omega)=|\text{STFT}(m,\omega)|^2
317
+ \]
318
+
319
+ Mel projection:
320
+
321
+ \[
322
+ M_{m,f}=\log\left(\epsilon + \sum_{\omega}H_f(\omega)P(m,\omega)\right)
323
+ \]
324
+
325
+ Mel scale:
326
+
327
+ \[
328
+ \text{mel}(f)=2595\log_{10}\left(1+\frac{f}{700}\right)
329
+ \]
330
+
331
+ Peak normalization:
332
+
333
+ \[
334
+ \widetilde{x}[n]=\frac{x[n]}{\max(\epsilon,\max_k |x[k]|)}
335
+ \]
336
+
337
+ Energy:
338
+
339
+ \[
340
+ e_m=\log\left(\epsilon + \frac{1}{F}\sum_{\omega}P(m,\omega)\right)
341
+ \]
342
+
343
+ Pitch:
344
+
345
+ \[
346
+ p_m = \log(1 + F_0(m))
347
+ \]
348
+
349
+ ## Implementation Notes
350
+
351
+ - `AudioFeatureExtractor` uses `torchaudio` for loading, resampling, and pitch detection.
352
+ - `build_mel_filterbank` explicitly constructs the mel basis rather than hiding it behind a monolithic transform.
353
+ - `normalize_text` and `naive_phonemize` provide a CPU-safe fallback path.
354
+ - preprocessing is offline by default to reduce repeated FFT work during training.
355
+
356
+ ## Silence and VAD
357
+
358
+ Current code includes energy-based trimming. Production upgrades should add:
359
+
360
+ - WebRTC VAD
361
+ - framewise voiced/unvoiced masks
362
+ - language-specific pause retention logic
363
+
364
+ ## Alignment
365
+
366
+ The current blueprint uses duration heuristics as a bootstrap. For full training:
367
+
368
+ - use Montreal Forced Aligner
369
+ - or an internal CTC/attention aligner
370
+ - export token or phoneme durations into the manifest or feature cache
371
+
372
+ ---
373
+
374
+ # 5. Speaker Encoder Training
375
+
376
+ Files:
377
+
378
+ - `model/speaker_encoder.py`
379
+ - `datasets/speaker_dataset.py`
380
+ - `training/speaker_trainer.py`
381
+
382
+ ## Architecture
383
+
384
+ 1. Convolutional frontend over mel frames.
385
+ 2. Sparse temporal encoder.
386
+ 3. Laminar refinement.
387
+ 4. Attentive statistics pooling.
388
+ 5. L2-normalized speaker projection.
389
+
390
+ Mathematically:
391
+
392
+ \[
393
+ z_s = f_{\text{speaker}}(M)
394
+ \]
395
+
396
+ where:
397
+
398
+ - \(M \in \mathbb{R}^{B \times T \times F_{\text{mel}}}\)
399
+ - \(z_s \in \mathbb{R}^{B \times d_s}\)
400
+
401
+ ## Losses
402
+
403
+ Triplet loss:
404
+
405
+ \[
406
+ L = \max(0, d(a,p) - d(a,n) + m)
407
+ \]
408
+
409
+ where \(d(\cdot,\cdot)\) is cosine or angular distance.
410
+
411
+ Contrastive / supervised contrastive auxiliary loss:
412
+
413
+ \[
414
+ \mathcal{L}_{\text{con}} = -\log \frac{\sum_{p \in P(i)} \exp(\text{sim}(z_i,z_p)/\tau)}
415
+ {\sum_{j \ne i}\exp(\text{sim}(z_i,z_j)/\tau)}
416
+ \]
417
+
418
+ ## Batching Strategy
419
+
420
+ - sample anchor, positive, negative triplets by speaker id
421
+ - keep variable-length batch padding local
422
+ - periodically mine hard negatives from an embedding memory bank
423
+
424
+ ## Hard Negative Mining
425
+
426
+ Practical CPU-first approach:
427
+
428
+ 1. embed a speaker minibatch
429
+ 2. compute cosine matrix
430
+ 3. choose negatives with highest non-matching cosine
431
+
432
+ ## Augmentation Pipeline
433
+
434
+ - additive noise
435
+ - room impulse responses
436
+ - mild codec artifacts
437
+ - small gain shifts
438
+
439
+ Use conservative augmentation to avoid collapsing speaker identity.
440
+
441
+ ---
442
+
443
+ # 6. Semantic Sparse Encoder Training
444
+
445
+ Files:
446
+
447
+ - `datasets/tts_dataset.py`
448
+ - `model/low_rank_qkv.py`
449
+ - `model/sparse_attention.py`
450
+ - `model/laminar.py`
451
+ - `model/memory.py`
452
+ - `model/semantic_encoder.py`
453
+
454
+ ## Tokenization
455
+
456
+ The current code includes a lightweight phoneme-like tokenizer built from normalized text. Production upgrades should provide:
457
+
458
+ - phoneme tokenizer
459
+ - multilingual symbol tables
460
+ - byte fallback for out-of-vocabulary text
461
+
462
+ ## Tensor Shapes
463
+
464
+ - tokens: \(u \in \mathbb{N}^{B \times T_{\text{txt}}}\)
465
+ - embeddings: \(X \in \mathbb{R}^{B \times T_{\text{txt}} \times d}\)
466
+ - hidden states: \(H \in \mathbb{R}^{B \times T_{\text{txt}} \times d}\)
467
+
468
+ ## Low-Rank QKV
469
+
470
+ \[
471
+ W_Q = U_QV_Q^\top,\quad W_K = U_KV_K^\top,\quad W_V = U_VV_V^\top
472
+ \]
473
+
474
+ with \(U \in \mathbb{R}^{d \times r}\), \(V \in \mathbb{R}^{d \times r}\), \(r \ll d\).
475
+
476
+ ## Sparse Attention
477
+
478
+ For query \(i\), candidate set:
479
+
480
+ \[
481
+ \mathcal{C}_i = \mathcal{C}^{\text{local}}_i \cup \mathcal{C}^{\text{memory}}_i \cup \mathcal{C}^{\text{landmark}}_i \cup \mathcal{C}^{\text{content}}_i
482
+ \]
483
+
484
+ Score:
485
+
486
+ \[
487
+ S_{ij}=w_1(Q_i^\top K_j)+w_2P_{ij}+w_3M_{ij}+w_4R_{ij}
488
+ \]
489
+
490
+ Normalization:
491
+
492
+ - top-k sparse softmax
493
+ - sparsemax
494
+
495
+ ## Laminar Refinement
496
+
497
+ \[
498
+ h_i \leftarrow h_i + \eta(E_i-I_i)
499
+ \]
500
+
501
+ This stage improves stability while retaining shallow compute depth.
502
+
503
+ ## CPU Optimization Notes
504
+
505
+ - local window candidates are contiguous and cache-friendly
506
+ - content retrieval is bounded to a small top-k
507
+ - memory summaries compress long-range context
508
+ - low-rank projections reduce parameter bandwidth
509
+
510
+ ---
511
+
512
+ # 7. Prosody Modeling
513
+
514
+ Files:
515
+
516
+ - `model/prosody.py`
517
+
518
+ ## Heads
519
+
520
+ - duration predictor
521
+ - pitch predictor
522
+ - energy predictor
523
+ - FiLM speaker conditioning
524
+ - length regulator
525
+
526
+ ## Equations
527
+
528
+ Duration:
529
+
530
+ \[
531
+ \widehat{d}_t = \text{softplus}(w_d^\top h_t + b_d)
532
+ \]
533
+
534
+ Pitch:
535
+
536
+ \[
537
+ \widehat{p}_t = w_p^\top h_t + b_p
538
+ \]
539
+
540
+ Energy:
541
+
542
+ \[
543
+ \widehat{e}_t = w_e^\top h_t + b_e
544
+ \]
545
+
546
+ Speaker FiLM:
547
+
548
+ \[
549
+ h'_t = \gamma(z_s) \odot h_t + \beta(z_s)
550
+ \]
551
+
552
+ Length regulation:
553
+
554
+ \[
555
+ \widetilde{H} = \text{Expand}(H, \widehat{d})
556
+ \]
557
+
558
+ ## Rhythm Modeling
559
+
560
+ Prosody is modeled explicitly so the acoustic decoder does not need to discover:
561
+
562
+ - alignment
563
+ - stress
564
+ - speaking rate
565
+ - energy contour
566
+
567
+ from scratch with dense frame attention.
568
+
569
+ ---
570
+
571
+ # 8. Acoustic Decoder Training
572
+
573
+ Files:
574
+
575
+ - `model/acoustic_decoder.py`
576
+ - `model/mel_generator.py`
577
+ - `training/acoustic_trainer.py`
578
+
579
+ ## Architecture
580
+
581
+ ```text
582
+ semantic latent
583
+ + speaker latent
584
+ + pitch
585
+ + energy
586
+ -> sparse acoustic decoder
587
+ -> energy head
588
+ -> mel spectrogram
589
+ ```
590
+
591
+ The decoder uses sparse temporal attention again, but at the frame-expanded acoustic level.
592
+
593
+ ## Teacher Forcing and Scheduled Sampling
594
+
595
+ Current blueprint uses target durations, target pitch, and target energy when provided.
596
+
597
+ Recommended progression:
598
+
599
+ 1. full teacher forcing with ground-truth durations
600
+ 2. predicted duration with teacher-forced prosody
601
+ 3. scheduled sampling on duration and prosody
602
+ 4. chunk-wise streaming teacher forcing
603
+
604
+ ## Losses
605
+
606
+ - mel L1
607
+ - duration loss
608
+ - pitch loss
609
+ - energy loss
610
+ - optional STFT or alignment losses
611
+
612
+ Alignment loss can be added as:
613
+
614
+ \[
615
+ \mathcal{L}_{\text{align}} = \|A - A^\*\|_1
616
+ \]
617
+
618
+ where \(A^\*\) is an external aligner target.
619
+
620
+ ---
621
+
622
+ # 9. Sparse Vocoder Training
623
+
624
+ Files:
625
+
626
+ - `vocoder/sparse_vocoder.py`
627
+ - `vocoder/discriminator.py`
628
+ - `training/vocoder_trainer.py`
629
+
630
+ ## Architecture
631
+
632
+ - causal transposed-convolution upsamplers
633
+ - sparse residual blocks
634
+ - compact adversarial discriminator
635
+
636
+ Autoregressive probability:
637
+
638
+ \[
639
+ P(x)=\prod_t P(x_t \mid x_{<t}, M, z_s)
640
+ \]
641
+
642
+ The provided generator is a practical hybrid: conditioning is mel-driven and causal, but the structure is simplified for CPU deployment.
643
+
644
+ ## Losses
645
+
646
+ - multi-resolution STFT loss
647
+ - waveform adversarial loss
648
+ - optional feature matching loss
649
+
650
+ Generator loss:
651
+
652
+ \[
653
+ \mathcal{L}_G = \lambda_{\text{stft}}\mathcal{L}_{\text{MR-STFT}} + \lambda_{\text{gan}}\mathcal{L}_{\text{GAN}}^G
654
+ \]
655
+
656
+ Discriminator loss:
657
+
658
+ \[
659
+ \mathcal{L}_D = \mathbb{E}[\max(0,1-D(x))] + \mathbb{E}[\max(0,1+D(\widehat{x}))]
660
+ \]
661
+
662
+ ---
663
+
664
+ # 10. Training Orchestration
665
+
666
+ Files:
667
+
668
+ - `training/trainer.py`
669
+ - `training/checkpoint.py`
670
+ - `training/distributed.py`
671
+
672
+ ## Optimizer and Scheduling
673
+
674
+ - `AdamW`
675
+ - cosine decay
676
+ - warmup
677
+ - gradient clipping
678
+ - gradient accumulation
679
+
680
+ ## Checkpointing
681
+
682
+ `CheckpointManager` saves:
683
+
684
+ - model weights
685
+ - optimizer state
686
+ - global step
687
+ - metrics snapshot
688
+
689
+ ## Distributed Training
690
+
691
+ The current blueprint includes `gloo`-based initialization for CPU-compatible distributed experiments.
692
+
693
+ Recommended CPU DDP usage:
694
+
695
+ ```bash
696
+ torchrun --nproc_per_node=4 -m bio_voice_tts.scripts.train_tts --config bio_voice_tts/configs/base.yaml
697
+ ```
698
+
699
+ ## Curriculum and Freezing
700
+
701
+ Recommended staging:
702
+
703
+ 1. pretrain speaker encoder
704
+ 2. freeze speaker encoder and train semantic + prosody + acoustic stack
705
+ 3. train vocoder on ground-truth mels
706
+ 4. unfreeze speaker encoder partially for joint refinement
707
+ 5. enable streaming chunk curriculum
708
+
709
+ ---
710
+
711
+ # 11. Streaming Training
712
+
713
+ Files:
714
+
715
+ - `streaming/cache.py`
716
+ - `streaming/realtime_stream.py`
717
+
718
+ ## Chunked Training
719
+
720
+ Simulate streaming by splitting sequences into chunks of size \(C\):
721
+
722
+ \[
723
+ Y = [Y^{(1)}, Y^{(2)}, \dots, Y^{(N)}]
724
+ \]
725
+
726
+ where each chunk is decoded with a bounded cache and bounded history.
727
+
728
+ ## KV-Cache Simulation
729
+
730
+ For each chunk:
731
+
732
+ - preserve previous mel summaries
733
+ - preserve acoustic hidden summaries
734
+ - bound cached frames to `streaming_cache_frames`
735
+
736
+ ## Latency Optimization
737
+
738
+ - fixed chunk sizes
739
+ - fixed sparse candidate counts
740
+ - reuse speaker embedding
741
+ - keep cache tensors small and contiguous
742
+
743
+ ---
744
+
745
+ # 12. Configuration System
746
+
747
+ Files:
748
+
749
+ - `configs/base.yaml`
750
+ - `configs/speaker_encoder.yaml`
751
+ - `configs/acoustic_decoder.yaml`
752
+ - `configs/vocoder.yaml`
753
+ - `configs/datasets.yaml`
754
+ - `utils/config.py`
755
+
756
+ The config loader merges YAML payloads into nested dataclasses so training recipes remain type-safe and script-friendly.
757
+
758
+ Example:
759
+
760
+ ```yaml
761
+ training:
762
+ epochs: 100
763
+ batch_size: 4
764
+ learning_rate: 0.0002
765
+ semantic:
766
+ d_model: 256
767
+ low_rank: 32
768
+ ```
769
+
770
+ ---
771
+
772
+ # 13. Benchmarking
773
+
774
+ Files:
775
+
776
+ - `benchmarks/benchmark_cpu.py`
777
+
778
+ ## Metrics
779
+
780
+ - mean CPU latency
781
+ - peak memory usage
782
+ - mel frames per second
783
+ - chunk throughput
784
+ - streaming cache overhead
785
+
786
+ ## Dense vs Sparse Comparison
787
+
788
+ Dense attention cost:
789
+
790
+ \[
791
+ O(n^2d)
792
+ \]
793
+
794
+ Sparse attention cost:
795
+
796
+ \[
797
+ O(nkd)
798
+ \]
799
+
800
+ Benchmark protocol:
801
+
802
+ 1. fix token length
803
+ 2. fix mel frame length
804
+ 3. run sparse and dense variants
805
+ 4. compare latency and peak memory
806
+
807
+ ---
808
+
809
+ # 14. Evaluation
810
+
811
+ Files:
812
+
813
+ - `evaluation/metrics.py`
814
+ - `evaluation/evaluate.py`
815
+
816
+ ## Current Metrics
817
+
818
+ - mel MAE
819
+ - pseudo-MOS proxy
820
+ - speaker cosine similarity proxy
821
+
822
+ ## Recommended Production Metrics
823
+
824
+ - MOSNet or neural MOS proxy
825
+ - ASR-backed intelligibility and WER
826
+ - speaker verification cosine / EER
827
+ - multi-resolution STFT metrics
828
+ - streaming first-chunk latency
829
+ - real-time factor
830
+
831
+ WER compatibility is especially important for voice cloning because a model can sound speaker-correct while still degrading lexical accuracy.
832
+
833
+ ---
834
+
835
+ # 15. Inference System
836
+
837
+ Files:
838
+
839
+ - `inference/synthesize.py`
840
+ - `inference/clone_voice.py`
841
+ - `inference/realtime_stream.py`
842
+
843
+ Example:
844
+
845
+ ```bash
846
+ python -m bio_voice_tts.inference.clone_voice \
847
+ --config bio_voice_tts/configs/base.yaml \
848
+ --speaker sample.wav \
849
+ --text "welcome to the interview" \
850
+ --out out.wav
851
+ ```
852
+
853
+ Streaming:
854
+
855
+ ```bash
856
+ python -m bio_voice_tts.streaming.realtime_stream \
857
+ --config bio_voice_tts/configs/base.yaml \
858
+ --speaker sample.wav \
859
+ --text "welcome to the interview. this is a streaming test." \
860
+ --out stream.wav
861
+ ```
862
+
863
+ ---
864
+
865
+ # 16. Training Roadmap
866
+
867
+ ## Stage 1: Speaker Encoder
868
+
869
+ - data: VoxCeleb + VCTK + MLS speaker-balanced subsets
870
+ - objective: triplet + contrastive
871
+ - hardware: 16-32 CPU cores or modest single GPU
872
+ - CPU estimate: batch 4-16 depending on mel length
873
+ - duration: 1-5 days on CPU-only, much less on GPU
874
+
875
+ ## Stage 2: Semantic Encoder
876
+
877
+ - data: LibriTTS + MLS text/transcript-aligned corpora
878
+ - objective: stable sparse semantic representations
879
+ - batch: 4-8 CPU-friendly
880
+ - focus: tokenizer quality, sparse attention stability
881
+
882
+ ## Stage 3: Prosody Modules
883
+
884
+ - data: forced-aligned durations + extracted F0/energy
885
+ - objective: duration/pitch/energy prediction
886
+ - strategy: freeze speaker encoder initially
887
+
888
+ ## Stage 4: Acoustic Decoder
889
+
890
+ - objective: text+speaker+prosody to mel
891
+ - training: teacher forcing then scheduled sampling
892
+ - CPU estimate: batch 2-6 depending on frame length
893
+
894
+ ## Stage 5: Vocoder
895
+
896
+ - train on ground-truth mels first
897
+ - use multi-resolution STFT heavily
898
+ - add GAN gradually
899
+ - CPU estimate: slowest stage; consider GPU assist if available
900
+
901
+ ## Stage 6: Joint Finetuning
902
+
903
+ - partially unfreeze speaker encoder
904
+ - align text-to-mel with vocoder feedback
905
+ - use lower LR and aggressive checkpointing
906
+
907
+ ## Stage 7: Streaming Optimization
908
+
909
+ - chunk curriculum
910
+ - cache simulation
911
+ - latency-oriented benchmarking
912
+ - prune or quantize dense remnants
913
+
914
+ ## Practical Hardware Expectations
915
+
916
+ - minimal CPU research box:
917
+ - 16 cores
918
+ - 64 GB RAM
919
+ - comfortable CPU box:
920
+ - 32 to 64 cores
921
+ - 128 GB RAM
922
+ - optional GPU acceleration:
923
+ - 16 GB VRAM helps with vocoder and adversarial training
924
+
925
+ CPU-first does not mean CPU-only forever. It means the architecture remains deployable and trainable in low-memory environments and does not assume giant dense accelerators.
926
+
927
+ ---
928
+
929
+ # 17. Future Research
930
+
931
+ - multilingual cloning with language-conditioned phoneme spaces
932
+ - emotional speech latents disentangled from speaker identity
933
+ - sparse diffusion or consistency vocoders
934
+ - unified speech foundation models with shared acoustic latents
935
+ - TTS/STT shared sparse front-ends
936
+ - duplex speech agents with interruption handling
937
+ - persistent speaker memory that updates over repeated interactions
938
+
939
+ ---
940
+
941
+ ## Implementation Status
942
+
943
+ The code shipped with this blueprint is intentionally structured as a strong research foundation rather than a fully benchmarked production release. It already provides:
944
+
945
+ - real PyTorch modules
946
+ - low-rank sparse attention
947
+ - laminar refinement
948
+ - speaker encoder training scaffolding
949
+ - text-to-mel training scaffolding
950
+ - sparse vocoder scaffolding
951
+ - chunked streaming cache support
952
+ - YAML configs
953
+ - benchmarking and evaluation hooks
954
+
955
+ The main gaps for a full-scale deployment are:
956
+
957
+ - forced alignment integration
958
+ - multilingual phonemization backends
959
+ - large-scale distributed dataset recipes
960
+ - richer vocoder feature matching
961
+ - real ASR-backed intelligibility evaluation
962
+ - quantization/export tooling
963
+
964
+ Those are natural next steps, not architectural blockers.
bio_voice_tts/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """BioVoice-TTS package."""
2
+
3
+ from .model.biovoice_tts import BioVoiceTTS
4
+ from .utils.config import BioVoiceConfig, load_config
5
+
6
+ __all__ = ["BioVoiceTTS", "BioVoiceConfig", "load_config"]
bio_voice_tts/audio/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Audio feature extraction primitives."""
bio_voice_tts/audio/features.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ import torch
7
+ import torch.nn.functional as F
8
+ import torchaudio
9
+ from scipy.io import wavfile
10
+
11
+ from .mel import build_mel_filterbank
12
+ from .stft import STFT, STFTConfig
13
+
14
+
15
+ @dataclass
16
+ class FeatureBatch:
17
+ waveform: torch.Tensor
18
+ mel: torch.Tensor
19
+ log_energy: torch.Tensor
20
+ pitch: torch.Tensor
21
+ voiced_mask: torch.Tensor
22
+
23
+
24
+ class AudioFeatureExtractor:
25
+ """CPU-first feature extraction with minimal temporary allocations."""
26
+
27
+ def __init__(
28
+ self,
29
+ sample_rate: int = 24000,
30
+ n_fft: int = 1024,
31
+ win_length: int = 1024,
32
+ hop_length: int = 256,
33
+ n_mels: int = 80,
34
+ f_min: float = 0.0,
35
+ f_max: float = 12000.0,
36
+ trim_db: float = 32.0,
37
+ ):
38
+ self.sample_rate = sample_rate
39
+ self.trim_db = trim_db
40
+ self.stft = STFT(STFTConfig(sample_rate, n_fft, win_length, hop_length))
41
+ self.mel_basis = build_mel_filterbank(sample_rate, n_fft, n_mels, f_min, f_max)
42
+
43
+ def load_audio(self, path: str) -> torch.Tensor:
44
+ try:
45
+ waveform, sample_rate = torchaudio.load(path)
46
+ waveform = waveform.mean(dim=0, keepdim=True)
47
+ except (ImportError, OSError):
48
+ sample_rate, waveform_np = wavfile.read(path)
49
+ waveform_np = np.asarray(waveform_np)
50
+ if waveform_np.ndim == 2:
51
+ waveform_np = waveform_np.mean(axis=1)
52
+ if np.issubdtype(waveform_np.dtype, np.integer):
53
+ waveform_np = waveform_np.astype(np.float32) / np.iinfo(waveform_np.dtype).max
54
+ else:
55
+ waveform_np = waveform_np.astype(np.float32)
56
+ waveform = torch.from_numpy(waveform_np).unsqueeze(0)
57
+ if sample_rate != self.sample_rate:
58
+ waveform = torchaudio.functional.resample(waveform, sample_rate, self.sample_rate)
59
+ return waveform.squeeze(0)
60
+
61
+ def trim_silence(self, waveform: torch.Tensor) -> torch.Tensor:
62
+ energy = waveform.abs()
63
+ threshold = energy.max() * 10 ** (-self.trim_db / 20.0)
64
+ mask = energy > threshold
65
+ if not torch.any(mask):
66
+ return waveform
67
+ start = int(mask.float().argmax().item())
68
+ end = int(mask.numel() - mask.flip(0).float().argmax().item())
69
+ return waveform[start:end]
70
+
71
+ def compute_pitch(self, waveform: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
72
+ frame_time = self.stft.config.hop_length / self.sample_rate
73
+ pitch = torchaudio.functional.detect_pitch_frequency(
74
+ waveform.unsqueeze(0),
75
+ self.sample_rate,
76
+ frame_time=frame_time,
77
+ ).squeeze(0)
78
+ voiced_mask = pitch > 1.0
79
+ return pitch.log1p(), voiced_mask
80
+
81
+ def compute_features(self, waveform: torch.Tensor) -> FeatureBatch:
82
+ waveform = self.trim_silence(waveform)
83
+ waveform = waveform / waveform.abs().max().clamp_min(1e-6)
84
+ spec = self.stft.forward(waveform)
85
+ if spec.dim() == 3:
86
+ spec = spec.squeeze(0)
87
+ power_spec = spec.abs().pow(2.0)
88
+ mel = torch.matmul(self.mel_basis.to(power_spec.device), power_spec)
89
+ mel = torch.log(mel.clamp_min(1e-5)).transpose(0, 1)
90
+ log_energy = torch.log(power_spec.mean(dim=0).clamp_min(1e-5))
91
+ pitch, voiced_mask = self.compute_pitch(waveform)
92
+ frame_count = mel.size(0)
93
+ pitch = F.pad(pitch, (0, max(0, frame_count - pitch.numel())))[:frame_count]
94
+ voiced_mask = F.pad(voiced_mask.float(), (0, max(0, frame_count - voiced_mask.numel())))[:frame_count].bool()
95
+ return FeatureBatch(
96
+ waveform=waveform,
97
+ mel=mel,
98
+ log_energy=log_energy[:frame_count],
99
+ pitch=pitch,
100
+ voiced_mask=voiced_mask,
101
+ )
bio_voice_tts/audio/mel.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import torch
5
+
6
+
7
+ def hz_to_mel(freq_hz: np.ndarray) -> np.ndarray:
8
+ return 2595.0 * np.log10(1.0 + freq_hz / 700.0)
9
+
10
+
11
+ def mel_to_hz(mel: np.ndarray) -> np.ndarray:
12
+ return 700.0 * (10 ** (mel / 2595.0) - 1.0)
13
+
14
+
15
+ def build_mel_filterbank(
16
+ sample_rate: int,
17
+ n_fft: int,
18
+ n_mels: int,
19
+ f_min: float,
20
+ f_max: float,
21
+ ) -> torch.Tensor:
22
+ mel_min = hz_to_mel(np.array([f_min], dtype=np.float64))[0]
23
+ mel_max = hz_to_mel(np.array([f_max], dtype=np.float64))[0]
24
+ mel_points = np.linspace(mel_min, mel_max, n_mels + 2)
25
+ hz_points = mel_to_hz(mel_points)
26
+ bins = np.floor((n_fft + 1) * hz_points / sample_rate).astype(np.int64)
27
+ fb = np.zeros((n_mels, n_fft // 2 + 1), dtype=np.float32)
28
+ for idx in range(1, n_mels + 1):
29
+ left, center, right = bins[idx - 1], bins[idx], bins[idx + 1]
30
+ center = max(center, left + 1)
31
+ right = max(right, center + 1)
32
+ for fft_bin in range(left, center):
33
+ fb[idx - 1, fft_bin] = (fft_bin - left) / max(1, center - left)
34
+ for fft_bin in range(center, right):
35
+ fb[idx - 1, fft_bin] = (right - fft_bin) / max(1, right - center)
36
+ return torch.from_numpy(fb)
bio_voice_tts/audio/phoneme.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+
6
+ def normalize_text(text: str) -> str:
7
+ text = text.lower().strip()
8
+ text = re.sub(r"\s+", " ", text)
9
+ text = re.sub(r"[^a-z0-9,;:!?.' -]", "", text)
10
+ return text
11
+
12
+
13
+ def naive_phonemize(text: str) -> list[str]:
14
+ """Fallback grapheme-to-phoneme approximation for CPU-only pipelines."""
15
+
16
+ normalized = normalize_text(text)
17
+ phonemes: list[str] = []
18
+ for token in normalized.split():
19
+ phonemes.extend(list(token))
20
+ phonemes.append("|")
21
+ return phonemes[:-1] if phonemes else []
bio_voice_tts/audio/stft.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import torch
6
+
7
+
8
+ @dataclass
9
+ class STFTConfig:
10
+ sample_rate: int = 24000
11
+ n_fft: int = 1024
12
+ win_length: int = 1024
13
+ hop_length: int = 256
14
+
15
+
16
+ class STFT(torch.nn.Module):
17
+ r"""Short-time Fourier transform.
18
+
19
+ X(m, \omega) = \sum_{n=0}^{N-1} x[n] w[n - mH] e^{-j \omega n}
20
+ """
21
+
22
+ def __init__(self, config: STFTConfig):
23
+ super().__init__()
24
+ self.config = config
25
+ self.register_buffer("window", torch.hann_window(config.win_length), persistent=False)
26
+
27
+ def forward(self, waveform: torch.Tensor) -> torch.Tensor:
28
+ if waveform.dim() == 1:
29
+ waveform = waveform.unsqueeze(0)
30
+ return torch.stft(
31
+ waveform,
32
+ n_fft=self.config.n_fft,
33
+ hop_length=self.config.hop_length,
34
+ win_length=self.config.win_length,
35
+ window=self.window,
36
+ center=True,
37
+ return_complex=True,
38
+ )
39
+
40
+ def magnitude(self, waveform: torch.Tensor) -> torch.Tensor:
41
+ return self.forward(waveform).abs()
bio_voice_tts/benchmarks/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Benchmark helpers."""
bio_voice_tts/benchmarks/benchmark_cpu.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import tracemalloc
6
+ from pathlib import Path
7
+
8
+ import torch
9
+
10
+ from bio_voice_tts.model.biovoice_tts import BioVoiceTTS
11
+
12
+
13
+ def benchmark_model(model: BioVoiceTTS, token_ids: torch.Tensor, reference_mel: torch.Tensor, iterations: int = 5) -> dict[str, float]:
14
+ latencies = []
15
+ tracemalloc.start()
16
+ for _ in range(iterations):
17
+ start = time.perf_counter()
18
+ with torch.no_grad():
19
+ _ = model(token_ids, reference_mel)
20
+ latencies.append(time.perf_counter() - start)
21
+ _, peak = tracemalloc.get_traced_memory()
22
+ tracemalloc.stop()
23
+ mel_frames = float(reference_mel.size(1))
24
+ return {
25
+ "mean_latency_seconds": sum(latencies) / len(latencies),
26
+ "peak_memory_mb": peak / (1024 * 1024),
27
+ "mel_frames_per_second": mel_frames / max(1e-6, sum(latencies) / len(latencies)),
28
+ }
29
+
30
+
31
+ def save_benchmark(payload: dict[str, float], path: str) -> None:
32
+ Path(path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
bio_voice_tts/configs/acoustic_decoder.yaml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ semantic:
2
+ d_model: 256
3
+ low_rank: 32
4
+ top_k: 12
5
+ acoustic:
6
+ d_model: 256
7
+ low_rank: 32
8
+ top_k: 24
9
+ local_window: 48
10
+ chunk_size: 24
11
+ training:
12
+ epochs: 100
13
+ batch_size: 4
14
+ output_dir: artifacts/biovoice_acoustic
bio_voice_tts/configs/base.yaml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ audio:
2
+ sample_rate: 24000
3
+ n_fft: 1024
4
+ win_length: 1024
5
+ hop_length: 256
6
+ n_mels: 80
7
+ dataset:
8
+ train_manifest: data/train_manifest.jsonl
9
+ eval_manifest: data/eval_manifest.jsonl
10
+ feature_dir: data/features
11
+ semantic:
12
+ vocab_size: 4096
13
+ d_model: 256
14
+ num_heads: 4
15
+ low_rank: 32
16
+ top_k: 12
17
+ local_window: 32
18
+ max_positions: 512
19
+ speaker:
20
+ input_dim: 80
21
+ conv_channels: 128
22
+ embedding_dim: 192
23
+ prosody:
24
+ d_model: 256
25
+ hidden_dim: 128
26
+ acoustic:
27
+ d_model: 256
28
+ speaker_dim: 192
29
+ n_mels: 80
30
+ low_rank: 32
31
+ top_k: 24
32
+ local_window: 48
33
+ chunk_size: 24
34
+ vocoder:
35
+ n_mels: 80
36
+ channels: 128
37
+ residual_layers: 6
38
+ training:
39
+ epochs: 10
40
+ batch_size: 2
41
+ learning_rate: 0.0002
42
+ grad_accum_steps: 2
43
+ output_dir: artifacts/biovoice_base
bio_voice_tts/configs/datasets.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ datasets:
2
+ train_manifest: data/train_manifest.jsonl
3
+ eval_manifest: data/eval_manifest.jsonl
4
+ feature_dir: data/features
5
+ sample_rate: 24000
6
+ transcript_format: jsonl
bio_voice_tts/configs/speaker_encoder.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ training:
2
+ epochs: 20
3
+ batch_size: 8
4
+ learning_rate: 0.001
5
+ output_dir: artifacts/biovoice_speaker
6
+ speaker:
7
+ conv_channels: 128
8
+ embedding_dim: 192
bio_voice_tts/configs/vocoder.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ vocoder:
2
+ channels: 128
3
+ residual_layers: 6
4
+ upsample_scales: [8, 5, 3, 2]
5
+ training:
6
+ epochs: 120
7
+ batch_size: 8
8
+ learning_rate: 0.0002
9
+ output_dir: artifacts/biovoice_vocoder
bio_voice_tts/datasets/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Manifest-driven datasets for TTS and speaker training."""
bio_voice_tts/datasets/manifest.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass
9
+ class ManifestEntry:
10
+ audio_path: str
11
+ text: str
12
+ speaker_id: str
13
+ language: str = "en"
14
+ sample_rate: int = 24000
15
+ duration_seconds: float | None = None
16
+ feature_path: str | None = None
17
+
18
+
19
+ def load_manifest(path: str | Path) -> list[ManifestEntry]:
20
+ entries: list[ManifestEntry] = []
21
+ with Path(path).open("r", encoding="utf-8") as handle:
22
+ for line in handle:
23
+ if not line.strip():
24
+ continue
25
+ entries.append(ManifestEntry(**json.loads(line)))
26
+ return entries
bio_voice_tts/datasets/speaker_dataset.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from collections import defaultdict
5
+
6
+ import torch
7
+ from torch.utils.data import Dataset
8
+
9
+ from bio_voice_tts.audio.features import AudioFeatureExtractor
10
+ from bio_voice_tts.datasets.manifest import ManifestEntry
11
+
12
+
13
+ class SpeakerDataset(Dataset):
14
+ """Triplet-friendly dataset that samples anchor/positive/negative mels."""
15
+
16
+ def __init__(self, entries: list[ManifestEntry], extractor: AudioFeatureExtractor):
17
+ self.entries = entries
18
+ self.extractor = extractor
19
+ self.by_speaker: dict[str, list[ManifestEntry]] = defaultdict(list)
20
+ for entry in entries:
21
+ self.by_speaker[entry.speaker_id].append(entry)
22
+ self.speakers = sorted(self.by_speaker)
23
+
24
+ def __len__(self) -> int:
25
+ return len(self.entries)
26
+
27
+ def _mel(self, entry: ManifestEntry) -> torch.Tensor:
28
+ return self.extractor.compute_features(self.extractor.load_audio(entry.audio_path)).mel
29
+
30
+ def __getitem__(self, index: int) -> dict[str, torch.Tensor | str]:
31
+ anchor = self.entries[index]
32
+ positive_pool = [entry for entry in self.by_speaker[anchor.speaker_id] if entry.audio_path != anchor.audio_path]
33
+ positive = random.choice(positive_pool or self.by_speaker[anchor.speaker_id])
34
+ negative_speaker = random.choice([speaker for speaker in self.speakers if speaker != anchor.speaker_id])
35
+ negative = random.choice(self.by_speaker[negative_speaker])
36
+ return {
37
+ "anchor_mel": self._mel(anchor),
38
+ "positive_mel": self._mel(positive),
39
+ "negative_mel": self._mel(negative),
40
+ "speaker_id": anchor.speaker_id,
41
+ }
42
+
43
+
44
+ def pad_mels(batch: list[dict[str, torch.Tensor | str]]) -> dict[str, torch.Tensor]:
45
+ def _pad(key: str) -> torch.Tensor:
46
+ tensors = [item[key] for item in batch]
47
+ assert all(isinstance(tensor, torch.Tensor) for tensor in tensors)
48
+ max_len = max(tensor.size(0) for tensor in tensors) # type: ignore[arg-type]
49
+ padded = []
50
+ for tensor in tensors: # type: ignore[assignment]
51
+ pad = max_len - tensor.size(0)
52
+ padded.append(torch.nn.functional.pad(tensor, (0, 0, 0, pad)))
53
+ return torch.stack(padded)
54
+
55
+ return {
56
+ "anchor_mel": _pad("anchor_mel"),
57
+ "positive_mel": _pad("positive_mel"),
58
+ "negative_mel": _pad("negative_mel"),
59
+ }
bio_voice_tts/datasets/tts_dataset.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import torch
7
+ from torch.utils.data import Dataset
8
+
9
+ from bio_voice_tts.audio.features import AudioFeatureExtractor
10
+ from bio_voice_tts.audio.phoneme import naive_phonemize, normalize_text
11
+ from bio_voice_tts.datasets.manifest import ManifestEntry
12
+
13
+
14
+ @dataclass
15
+ class TokenizerState:
16
+ stoi: dict[str, int]
17
+ itos: list[str]
18
+ pad_id: int = 0
19
+ bos_id: int = 1
20
+ eos_id: int = 2
21
+
22
+ @property
23
+ def vocab_size(self) -> int:
24
+ return len(self.itos)
25
+
26
+ def encode(self, text: str) -> list[int]:
27
+ phonemes = naive_phonemize(normalize_text(text))
28
+ ids = [self.bos_id]
29
+ ids.extend(self.stoi.get(token, self.stoi["<unk>"]) for token in phonemes)
30
+ ids.append(self.eos_id)
31
+ return ids
32
+
33
+
34
+ def build_tokenizer(entries: list[ManifestEntry]) -> TokenizerState:
35
+ vocab = ["<pad>", "<bos>", "<eos>", "<unk>"]
36
+ seen = set(vocab)
37
+ for entry in entries:
38
+ for token in naive_phonemize(normalize_text(entry.text)):
39
+ if token not in seen:
40
+ seen.add(token)
41
+ vocab.append(token)
42
+ return TokenizerState(stoi={token: idx for idx, token in enumerate(vocab)}, itos=vocab)
43
+
44
+
45
+ class TTSDataset(Dataset):
46
+ def __init__(self, entries: list[ManifestEntry], extractor: AudioFeatureExtractor, tokenizer: TokenizerState):
47
+ self.entries = entries
48
+ self.extractor = extractor
49
+ self.tokenizer = tokenizer
50
+
51
+ def _load_features(self, entry: ManifestEntry) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
52
+ if entry.feature_path:
53
+ feature_path = Path(entry.feature_path)
54
+ if feature_path.exists():
55
+ payload = torch.load(feature_path, map_location="cpu")
56
+ mel = payload["mel"].float()
57
+ pitch = payload["pitch"].float()
58
+ energy = payload["energy"].float()
59
+ frame_count = min(mel.size(0), pitch.size(0), energy.size(0))
60
+ return mel[:frame_count], pitch[:frame_count], energy[:frame_count]
61
+
62
+ features = self.extractor.compute_features(self.extractor.load_audio(entry.audio_path))
63
+ frame_count = min(features.mel.size(0), features.pitch.size(0), features.log_energy.size(0))
64
+ return features.mel[:frame_count], features.pitch[:frame_count], features.log_energy[:frame_count]
65
+
66
+ def __len__(self) -> int:
67
+ return len(self.entries)
68
+
69
+ def __getitem__(self, index: int) -> dict[str, torch.Tensor | str]:
70
+ entry = self.entries[index]
71
+ mel, pitch, energy = self._load_features(entry)
72
+ token_ids = torch.tensor(self.tokenizer.encode(entry.text), dtype=torch.long)
73
+ duration = max(1, mel.size(0) // max(1, token_ids.numel() - 2))
74
+ durations = torch.full((token_ids.numel(),), duration, dtype=torch.long)
75
+ return {
76
+ "token_ids": token_ids,
77
+ "mel": mel,
78
+ "pitch": pitch,
79
+ "energy": energy,
80
+ "durations": durations,
81
+ "speaker_id": entry.speaker_id,
82
+ "text": entry.text,
83
+ }
84
+
85
+
86
+ def tts_collate(batch: list[dict[str, torch.Tensor | str]]) -> dict[str, torch.Tensor]:
87
+ token_tensors = [item["token_ids"] for item in batch]
88
+ mel_tensors = [item["mel"] for item in batch]
89
+ pitch_tensors = [item["pitch"] for item in batch]
90
+ energy_tensors = [item["energy"] for item in batch]
91
+ duration_tensors = [item["durations"] for item in batch]
92
+ assert all(isinstance(tensor, torch.Tensor) for tensor in token_tensors + mel_tensors + pitch_tensors + energy_tensors + duration_tensors)
93
+
94
+ max_tokens = max(tensor.size(0) for tensor in token_tensors) # type: ignore[arg-type]
95
+ max_frames = max(tensor.size(0) for tensor in mel_tensors) # type: ignore[arg-type]
96
+ n_mels = mel_tensors[0].size(1) # type: ignore[index]
97
+
98
+ token_batch = torch.zeros(len(batch), max_tokens, dtype=torch.long)
99
+ duration_batch = torch.zeros(len(batch), max_tokens, dtype=torch.long)
100
+ mel_batch = torch.zeros(len(batch), max_frames, n_mels)
101
+ pitch_batch = torch.zeros(len(batch), max_frames)
102
+ energy_batch = torch.zeros(len(batch), max_frames)
103
+ token_lengths = torch.zeros(len(batch), dtype=torch.long)
104
+ frame_lengths = torch.zeros(len(batch), dtype=torch.long)
105
+
106
+ for idx, item in enumerate(batch):
107
+ tokens = item["token_ids"]
108
+ mel = item["mel"]
109
+ pitch = item["pitch"]
110
+ energy = item["energy"]
111
+ durations = item["durations"]
112
+ assert isinstance(tokens, torch.Tensor)
113
+ assert isinstance(mel, torch.Tensor)
114
+ assert isinstance(pitch, torch.Tensor)
115
+ assert isinstance(energy, torch.Tensor)
116
+ assert isinstance(durations, torch.Tensor)
117
+ token_batch[idx, : tokens.size(0)] = tokens
118
+ duration_batch[idx, : durations.size(0)] = durations
119
+ mel_batch[idx, : mel.size(0)] = mel
120
+ pitch_batch[idx, : pitch.size(0)] = pitch
121
+ energy_batch[idx, : energy.size(0)] = energy
122
+ token_lengths[idx] = tokens.size(0)
123
+ frame_lengths[idx] = mel.size(0)
124
+
125
+ return {
126
+ "token_ids": token_batch,
127
+ "durations": duration_batch,
128
+ "mel": mel_batch,
129
+ "pitch": pitch_batch,
130
+ "energy": energy_batch,
131
+ "token_lengths": token_lengths,
132
+ "frame_lengths": frame_lengths,
133
+ }
bio_voice_tts/evaluation/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Evaluation metrics and CLI helpers."""
bio_voice_tts/evaluation/evaluate.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import torch
7
+
8
+ from bio_voice_tts.datasets.tts_dataset import TTSDataset, build_tokenizer, tts_collate
9
+ from bio_voice_tts.evaluation.metrics import mel_mae, pseudo_mos
10
+ from bio_voice_tts.model.biovoice_tts import BioVoiceTTS
11
+ from bio_voice_tts.utils.config import load_config
12
+
13
+
14
+ @torch.no_grad()
15
+ def evaluate_model(config_path: str, checkpoint_path: str, entries, extractor) -> dict[str, float]:
16
+ config = load_config(config_path)
17
+ tokenizer = build_tokenizer(entries)
18
+ config.semantic.vocab_size = tokenizer.vocab_size
19
+ dataset = TTSDataset(entries, extractor, tokenizer)
20
+ batch = tts_collate([dataset[idx] for idx in range(min(2, len(dataset)))])
21
+ model = BioVoiceTTS(config)
22
+ state = torch.load(checkpoint_path, map_location="cpu")
23
+ model.load_state_dict(state["model"], strict=False)
24
+ model.eval()
25
+ outputs = model(batch["token_ids"], batch["mel"], batch["durations"], batch["pitch"], batch["energy"])
26
+ frame_limit = min(outputs["mel"].size(1), batch["mel"].size(1))
27
+ mae = mel_mae(outputs["mel"][:, :frame_limit], batch["mel"][:, :frame_limit])
28
+ mos = pseudo_mos(mae, torch.tensor(0.9))
29
+ return {"mel_mae": float(mae.item()), "pseudo_mos": float(mos.item())}
30
+
31
+
32
+ def save_evaluation(payload: dict[str, float], output: str) -> None:
33
+ Path(output).write_text(json.dumps(payload, indent=2), encoding="utf-8")
bio_voice_tts/evaluation/metrics.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+
6
+
7
+ def mel_mae(predicted: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
8
+ return (predicted - target).abs().mean()
9
+
10
+
11
+ def speaker_cosine_similarity(predicted: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
12
+ return F.cosine_similarity(predicted, target, dim=-1).mean()
13
+
14
+
15
+ def pseudo_mos(mel_error: torch.Tensor, speaker_similarity: torch.Tensor) -> torch.Tensor:
16
+ score = 5.0 - 2.0 * mel_error.clamp(max=1.5) + 0.5 * speaker_similarity.clamp(min=0.0, max=1.0)
17
+ return score.clamp(1.0, 5.0)
bio_voice_tts/inference/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Inference entrypoints for cloning and synthesis."""
bio_voice_tts/inference/clone_voice.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ import torch
6
+
7
+ from bio_voice_tts.inference.synthesize import build_dummy_tokenizer, synthesize
8
+ from bio_voice_tts.model.biovoice_tts import BioVoiceTTS
9
+ from bio_voice_tts.utils.config import load_config
10
+ from bio_voice_tts.vocoder.sparse_vocoder import SparseNeuralVocoder
11
+
12
+
13
+ def parse_args() -> argparse.Namespace:
14
+ parser = argparse.ArgumentParser(description="Clone a voice with BioVoice-TTS.")
15
+ parser.add_argument("--config", required=True)
16
+ parser.add_argument("--checkpoint", required=False)
17
+ parser.add_argument("--vocoder-checkpoint", required=False)
18
+ parser.add_argument("--speaker", required=True)
19
+ parser.add_argument("--text", required=True)
20
+ parser.add_argument("--out", default="out.wav")
21
+ return parser.parse_args()
22
+
23
+
24
+ def main() -> None:
25
+ args = parse_args()
26
+ config = load_config(args.config)
27
+ model = BioVoiceTTS(config)
28
+ vocoder = SparseNeuralVocoder(
29
+ n_mels=config.vocoder.n_mels,
30
+ channels=config.vocoder.channels,
31
+ residual_layers=config.vocoder.residual_layers,
32
+ upsample_scales=config.vocoder.upsample_scales,
33
+ )
34
+ if args.checkpoint:
35
+ state = torch.load(args.checkpoint, map_location="cpu")
36
+ model.load_state_dict(state["model"], strict=False)
37
+ if args.vocoder_checkpoint:
38
+ state = torch.load(args.vocoder_checkpoint, map_location="cpu")
39
+ vocoder.load_state_dict(state["model"], strict=False)
40
+ synthesize(model, vocoder, build_dummy_tokenizer(), args.text, args.speaker, args.out, sample_rate=config.audio.sample_rate)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
bio_voice_tts/inference/realtime_stream.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from bio_voice_tts.streaming.realtime_stream import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
bio_voice_tts/inference/synthesize.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torchaudio
5
+
6
+ from bio_voice_tts.audio.features import AudioFeatureExtractor
7
+ from bio_voice_tts.datasets.tts_dataset import TokenizerState, build_tokenizer
8
+ from bio_voice_tts.model.biovoice_tts import BioVoiceTTS
9
+ from bio_voice_tts.vocoder.sparse_vocoder import SparseNeuralVocoder
10
+
11
+
12
+ @torch.no_grad()
13
+ def synthesize(
14
+ model: BioVoiceTTS,
15
+ vocoder: SparseNeuralVocoder,
16
+ tokenizer: TokenizerState,
17
+ text: str,
18
+ reference_path: str,
19
+ out_path: str,
20
+ sample_rate: int = 24000,
21
+ ) -> None:
22
+ extractor = AudioFeatureExtractor(sample_rate=sample_rate)
23
+ features = extractor.compute_features(extractor.load_audio(reference_path))
24
+ token_ids = torch.tensor([tokenizer.encode(text)], dtype=torch.long)
25
+ outputs = model(token_ids, features.mel.unsqueeze(0))
26
+ waveform = vocoder(outputs["mel"]).cpu()
27
+ torchaudio.save(out_path, waveform.unsqueeze(1), sample_rate)
28
+
29
+
30
+ def build_dummy_tokenizer() -> TokenizerState:
31
+ vocab = ["<pad>", "<bos>", "<eos>", "<unk>"] + list("abcdefghijklmnopqrstuvwxyz|' ")
32
+ return TokenizerState(stoi={token: idx for idx, token in enumerate(vocab)}, itos=vocab)
bio_voice_tts/model/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """BioVoice-TTS model modules."""
bio_voice_tts/model/acoustic_decoder.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+ from .laminar import LaminarRefinement
7
+ from .sparse_attention import SparseAttention
8
+
9
+
10
+ class AcousticEnergyHead(nn.Module):
11
+ def __init__(self, d_model: int, speaker_dim: int, n_mels: int):
12
+ super().__init__()
13
+ self.proposal = nn.Linear(d_model, n_mels)
14
+ self.context_proj = nn.Linear(d_model, n_mels)
15
+ self.speaker_proj = nn.Linear(speaker_dim, n_mels)
16
+ self.memory_proj = nn.Linear(d_model, n_mels)
17
+ self.gates = nn.Linear(d_model + speaker_dim, 4)
18
+
19
+ def forward(self, states: torch.Tensor, speaker_latent: torch.Tensor, memory: torch.Tensor) -> dict[str, torch.Tensor]:
20
+ acoustic = self.proposal(states)
21
+ context = self.context_proj(states)
22
+ speaker = self.speaker_proj(speaker_latent).unsqueeze(1).expand_as(acoustic)
23
+ memory_term = self.memory_proj(memory)
24
+ gates = torch.softmax(self.gates(torch.cat([states, speaker_latent.unsqueeze(1).expand(states.size(0), states.size(1), -1)], dim=-1)), dim=-1)
25
+ mel = gates[..., 0:1] * acoustic + gates[..., 1:2] * context + gates[..., 2:3] * speaker + gates[..., 3:4] * memory_term
26
+ return {"mel": mel, "gates": gates, "acoustic": acoustic, "context": context, "speaker": speaker, "memory": memory_term}
27
+
28
+
29
+ class SparseAcousticDecoder(nn.Module):
30
+ def __init__(self, d_model: int, speaker_dim: int, n_mels: int, low_rank: int, top_k: int, local_window: int):
31
+ super().__init__()
32
+ self.input_proj = nn.Linear(d_model + speaker_dim + 2, d_model)
33
+ self.sparse_attention = SparseAttention(
34
+ d_model=d_model,
35
+ rank=low_rank,
36
+ top_k=top_k,
37
+ local_window=local_window,
38
+ memory_candidates=8,
39
+ landmark_count=4,
40
+ content_memory_candidates=4,
41
+ max_positions=4096,
42
+ )
43
+ self.laminar = LaminarRefinement(d_model, steps=2, eta=0.05)
44
+ self.memory = nn.Linear(d_model, d_model)
45
+ self.energy_head = AcousticEnergyHead(d_model, speaker_dim, n_mels)
46
+
47
+ def forward(
48
+ self,
49
+ frame_states: torch.Tensor,
50
+ speaker_latent: torch.Tensor,
51
+ pitch: torch.Tensor,
52
+ energy: torch.Tensor,
53
+ attention_mode: str = "topk",
54
+ ) -> dict[str, torch.Tensor]:
55
+ inputs = torch.cat([frame_states, speaker_latent.unsqueeze(1).expand(frame_states.size(0), frame_states.size(1), -1), pitch.unsqueeze(-1), energy.unsqueeze(-1)], dim=-1)
56
+ hidden = self.input_proj(inputs)
57
+ state = self.sparse_attention(hidden, mode=attention_mode)
58
+ refined = self.laminar(state.context, state.indices, state.weights)
59
+ memory = self.memory(refined)
60
+ mel_outputs = self.energy_head(refined, speaker_latent, memory)
61
+ return {
62
+ "hidden_states": refined,
63
+ "attention_weights": state.weights,
64
+ "attention_indices": state.indices,
65
+ **mel_outputs,
66
+ }
bio_voice_tts/model/biovoice_tts.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from torch import nn
6
+
7
+ from bio_voice_tts.utils.config import BioVoiceConfig
8
+
9
+ from .acoustic_decoder import SparseAcousticDecoder
10
+ from .prosody import DurationPredictor, EnergyPredictor, FiLMConditioner, PitchPredictor, length_regulate
11
+ from .semantic_encoder import SemanticEncoder
12
+ from .speaker_encoder import SpeakerEncoder
13
+
14
+
15
+ class BioVoiceTTS(nn.Module):
16
+ def __init__(self, config: BioVoiceConfig):
17
+ super().__init__()
18
+ self.config = config
19
+ self.speaker_encoder = SpeakerEncoder(
20
+ input_dim=config.audio.n_mels,
21
+ channels=config.speaker.conv_channels,
22
+ embedding_dim=config.speaker.embedding_dim,
23
+ )
24
+ self.semantic_encoder = SemanticEncoder(
25
+ vocab_size=config.semantic.vocab_size,
26
+ d_model=config.semantic.d_model,
27
+ low_rank=config.semantic.low_rank,
28
+ top_k=config.semantic.top_k,
29
+ local_window=config.semantic.local_window,
30
+ memory_candidates=config.semantic.memory_candidates,
31
+ landmark_count=config.semantic.landmark_count,
32
+ content_memory_candidates=config.semantic.content_memory_candidates,
33
+ laminar_steps=config.semantic.laminar_steps,
34
+ laminar_eta=config.semantic.laminar_eta,
35
+ max_positions=config.semantic.max_positions,
36
+ )
37
+ self.conditioner = FiLMConditioner(config.speaker.embedding_dim, config.semantic.d_model)
38
+ self.duration_predictor = DurationPredictor(config.semantic.d_model, config.prosody.hidden_dim)
39
+ self.pitch_predictor = PitchPredictor(config.semantic.d_model, config.prosody.hidden_dim)
40
+ self.energy_predictor = EnergyPredictor(config.semantic.d_model, config.prosody.hidden_dim)
41
+ self.acoustic_decoder = SparseAcousticDecoder(
42
+ d_model=config.acoustic.d_model,
43
+ speaker_dim=config.acoustic.speaker_dim,
44
+ n_mels=config.acoustic.n_mels,
45
+ low_rank=config.acoustic.low_rank,
46
+ top_k=config.acoustic.top_k,
47
+ local_window=config.acoustic.local_window,
48
+ )
49
+
50
+ @staticmethod
51
+ def _match_frame_length(values: torch.Tensor, target_frames: int) -> torch.Tensor:
52
+ if values.size(1) == target_frames:
53
+ return values
54
+ if values.size(1) > target_frames:
55
+ return values[:, :target_frames]
56
+ return F.pad(values, (0, target_frames - values.size(1)))
57
+
58
+ def forward(
59
+ self,
60
+ token_ids: torch.Tensor,
61
+ reference_mel: torch.Tensor,
62
+ target_durations: torch.Tensor | None = None,
63
+ target_pitch: torch.Tensor | None = None,
64
+ target_energy: torch.Tensor | None = None,
65
+ ) -> dict[str, torch.Tensor]:
66
+ speaker_latent = self.speaker_encoder(reference_mel)
67
+ semantic = self.semantic_encoder(token_ids)
68
+ conditioned = self.conditioner(semantic["hidden_states"], speaker_latent)
69
+ duration_pred = self.duration_predictor(conditioned)
70
+ durations = target_durations.float() if target_durations is not None else duration_pred
71
+ frame_states, frame_lengths = length_regulate(conditioned, durations, max_frames=self.config.dataset.max_mel_frames)
72
+ token_pitch = self.pitch_predictor(conditioned)
73
+ token_energy = self.energy_predictor(conditioned)
74
+ expanded_pitch, _ = length_regulate(token_pitch.unsqueeze(-1), durations, max_frames=frame_states.size(1))
75
+ expanded_energy, _ = length_regulate(token_energy.unsqueeze(-1), durations, max_frames=frame_states.size(1))
76
+ frame_pitch_pred = expanded_pitch.squeeze(-1)
77
+ frame_energy_pred = expanded_energy.squeeze(-1)
78
+ pitch = self._match_frame_length(target_pitch, frame_states.size(1)) if target_pitch is not None else frame_pitch_pred
79
+ energy = self._match_frame_length(target_energy, frame_states.size(1)) if target_energy is not None else frame_energy_pred
80
+ acoustic = self.acoustic_decoder(frame_states, speaker_latent, pitch, energy)
81
+ return {
82
+ "speaker_latent": speaker_latent,
83
+ "duration_pred": duration_pred,
84
+ "token_pitch_pred": token_pitch,
85
+ "token_energy_pred": token_energy,
86
+ "frame_pitch_pred": frame_pitch_pred,
87
+ "frame_energy_pred": frame_energy_pred,
88
+ "frame_lengths": frame_lengths,
89
+ **semantic,
90
+ **acoustic,
91
+ }
bio_voice_tts/model/laminar.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class LaminarRefinement(nn.Module):
8
+ def __init__(self, d_model: int, steps: int = 2, eta: float = 0.1):
9
+ super().__init__()
10
+ self.steps = steps
11
+ self.eta = eta
12
+ self.excitatory_proj = nn.Linear(d_model, d_model)
13
+ self.inhibitory_proj = nn.Linear(d_model, d_model)
14
+
15
+ def _weighted_sum(self, states: torch.Tensor, indices: torch.Tensor, weights: torch.Tensor, proj: nn.Linear) -> torch.Tensor:
16
+ batch, seq_len, _ = states.shape
17
+ mixed = torch.zeros_like(states)
18
+ projected = proj(states)
19
+ for batch_idx in range(batch):
20
+ for pos in range(seq_len):
21
+ idx = indices[batch_idx, pos]
22
+ w = weights[batch_idx, pos].unsqueeze(-1)
23
+ mixed[batch_idx, pos] = torch.sum(projected[batch_idx, idx] * w, dim=0)
24
+ return mixed
25
+
26
+ def forward(self, states: torch.Tensor, indices: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
27
+ refined = states
28
+ for _ in range(self.steps):
29
+ excitatory = self._weighted_sum(refined, indices, weights, self.excitatory_proj)
30
+ inhibitory = refined.cumsum(dim=1) - self._weighted_sum(refined, indices, weights, self.inhibitory_proj)
31
+ refined = refined + self.eta * (excitatory - inhibitory)
32
+ return refined