This commit is contained in:
2026-04-04 16:00:25 +03:00
parent 41c8d97094
commit abb4b31e60
8 changed files with 928 additions and 33 deletions

5
ga/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*
!**/
!.gitignore
!*.py

361
ga/ga.py Normal file
View File

@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""Genetic algorithm for optimizing meeting transcription+diarization pipeline.
Searches over a mixed discrete configuration space of transcription and
diarization models and their parameters. Uses module-level caching and batch
scheduling grouped by model to minimize redundant computations.
"""
import json
import random
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
import run_pipeline
# ---------------------------------------------------------------------------
# Configuration space
# ---------------------------------------------------------------------------
TRANSCRIPTION_MODELS = [
"whisper-large-v3",
"whisper-medium",
"faster-whisper-large-v3",
"gigaam-ctc",
"gigaam-rnnt",
]
BEAM_SIZES = [1, 3, 5, 7, 10]
VAD_THRESHOLDS = [0.3, 0.4, 0.5, 0.6, 0.7]
DIARIZATION_MODELS = ["pyannote-3.1", "pyannote-community-1", "sortformer"]
MIN_SPEECH_DURATIONS = [0.25, 0.5, 0.75, 1.0, 1.5]
CLUSTERING_THRESHOLDS = [0.3, 0.45, 0.6, 0.75, 0.9]
WHISPER_MODELS = {"whisper-large-v3", "whisper-medium", "faster-whisper-large-v3"}
GENES: list[tuple[str, list]] = [
("transcription_model", TRANSCRIPTION_MODELS),
("beam_size", BEAM_SIZES),
("vad_threshold", VAD_THRESHOLDS),
("diarization_model", DIARIZATION_MODELS),
("min_speech_duration", MIN_SPEECH_DURATIONS),
("clustering_threshold", CLUSTERING_THRESHOLDS),
]
# ---------------------------------------------------------------------------
# GA hyper-parameters
# ---------------------------------------------------------------------------
POPULATION_SIZE = 15
NUM_GENERATIONS = 25
TOURNAMENT_SIZE = 3
MUTATION_PROB = 0.15
ELITE_COUNT = 2
ALPHA = 0.4 # WER weight
BETA = 0.4 # DER weight
GAMMA = 0.2 # time weight
# ---------------------------------------------------------------------------
# Chromosome
# ---------------------------------------------------------------------------
@dataclass
class Chromosome:
genes: list[int]
fitness: float | None = None
wer: float | None = None
der: float | None = None
time_min: float | None = None
def to_config(self) -> dict:
return {
name: values[self.genes[i]] for i, (name, values) in enumerate(GENES)
}
def transcription_key(self) -> tuple:
cfg = self.to_config()
model = cfg["transcription_model"]
beam = cfg["beam_size"] if model in WHISPER_MODELS else 1
return (model, beam, cfg["vad_threshold"])
def diarization_key(self) -> tuple:
cfg = self.to_config()
return (
cfg["diarization_model"],
cfg["min_speech_duration"],
cfg["clustering_threshold"],
cfg["vad_threshold"],
)
def copy(self) -> "Chromosome":
return Chromosome(genes=self.genes.copy())
# ---------------------------------------------------------------------------
# Module-level cache
# ---------------------------------------------------------------------------
class Cache:
def __init__(self, cache_dir: Path):
self.cache_dir = cache_dir
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.transcription: dict[str, dict] = {}
self.diarization: dict[str, dict] = {}
self._load()
def _load(self):
for name in ("transcription", "diarization"):
path = self.cache_dir / f"{name}.json"
if path.exists():
setattr(self, name, json.loads(path.read_text()))
def save(self):
for name in ("transcription", "diarization"):
path = self.cache_dir / f"{name}.json"
path.write_text(json.dumps(getattr(self, name), indent=2))
def get_transcription(self, key: tuple) -> dict | None:
return self.transcription.get(str(key))
def set_transcription(self, key: tuple, result: dict):
self.transcription[str(key)] = result
def get_diarization(self, key: tuple) -> dict | None:
return self.diarization.get(str(key))
def set_diarization(self, key: tuple, result: dict):
self.diarization[str(key)] = result
# ---------------------------------------------------------------------------
# GA operators
# ---------------------------------------------------------------------------
def random_chromosome() -> Chromosome:
return Chromosome(genes=[random.randint(0, len(v) - 1) for _, v in GENES])
def tournament_select(population: list[Chromosome]) -> Chromosome:
candidates = random.sample(population, TOURNAMENT_SIZE)
return max(candidates, key=lambda c: c.fitness)
def crossover(p1: Chromosome, p2: Chromosome) -> Chromosome:
return Chromosome(
genes=[random.choice([g1, g2]) for g1, g2 in zip(p1.genes, p2.genes)]
)
def mutate(chrom: Chromosome) -> Chromosome:
genes = chrom.genes.copy()
for i, (_, values) in enumerate(GENES):
if random.random() < MUTATION_PROB:
if len(values) > 2 and random.random() < 0.7:
delta = random.choice([-1, 1])
genes[i] = max(0, min(len(values) - 1, genes[i] + delta))
else:
genes[i] = random.randint(0, len(values) - 1)
return Chromosome(genes=genes)
def compute_fitness(wer: float, der: float, time_min: float) -> float:
return -(ALPHA * wer + BETA * der + GAMMA * time_min)
# ---------------------------------------------------------------------------
# Batch scheduler
# ---------------------------------------------------------------------------
def schedule_evaluations(
population: list[Chromosome], cache: Cache, audio_paths: list[str]
) -> int:
"""Evaluate chromosomes using cache and batching by model.
1. Collect unique uncached transcription and diarization configs.
2. Group them by model so the pipeline loads each model only once.
3. Store results in cache and assemble fitness values.
Returns the number of new (uncached) module evaluations performed.
"""
uncached_t: dict[str, list[tuple[tuple, dict]]] = defaultdict(list)
uncached_d: dict[str, list[tuple[tuple, dict]]] = defaultdict(list)
seen_t: set[str] = set()
seen_d: set[str] = set()
for chrom in population:
cfg = chrom.to_config()
t_key = chrom.transcription_key()
t_key_s = str(t_key)
if cache.get_transcription(t_key) is None and t_key_s not in seen_t:
seen_t.add(t_key_s)
model = cfg["transcription_model"]
beam = cfg["beam_size"] if model in WHISPER_MODELS else 1
uncached_t[model].append(
(t_key, {"beam_size": beam, "vad_threshold": cfg["vad_threshold"]})
)
d_key = chrom.diarization_key()
d_key_s = str(d_key)
if cache.get_diarization(d_key) is None and d_key_s not in seen_d:
seen_d.add(d_key_s)
uncached_d[cfg["diarization_model"]].append(
(
d_key,
{
"min_speech_duration": cfg["min_speech_duration"],
"clustering_threshold": cfg["clustering_threshold"],
"vad_threshold": cfg["vad_threshold"],
},
)
)
new_evals = 0
for model, items in uncached_t.items():
configs = [c for _, c in items]
results = run_pipeline.evaluate_transcription_batch(
model, configs, audio_paths
)
for (key, _), result in zip(items, results):
cache.set_transcription(key, result)
new_evals += 1
for model, items in uncached_d.items():
configs = [c for _, c in items]
results = run_pipeline.evaluate_diarization_batch(
model, configs, audio_paths
)
for (key, _), result in zip(items, results):
cache.set_diarization(key, result)
new_evals += 1
if new_evals > 0:
cache.save()
for chrom in population:
t_res = cache.get_transcription(chrom.transcription_key())
d_res = cache.get_diarization(chrom.diarization_key())
chrom.wer = t_res["wer"]
chrom.der = d_res["der"]
chrom.time_min = t_res["time"] + d_res["time"]
chrom.fitness = compute_fitness(chrom.wer, chrom.der, chrom.time_min)
return new_evals
# ---------------------------------------------------------------------------
# Main GA loop
# ---------------------------------------------------------------------------
def run_ga(audio_paths: list[str] | None = None, seed: int = 42) -> list[dict]:
random.seed(seed)
if audio_paths is None:
audio_paths = []
cache = Cache(Path(__file__).parent / "cache")
history: list[dict] = []
all_configs: list[dict] = []
seen_genes: set[tuple[int, ...]] = set()
total_evals = 0
population = [random_chromosome() for _ in range(POPULATION_SIZE)]
new_evals = schedule_evaluations(population, cache, audio_paths)
total_evals += new_evals
for gen in range(NUM_GENERATIONS):
population.sort(key=lambda c: c.fitness, reverse=True)
for chrom in population:
key = tuple(chrom.genes)
if key not in seen_genes:
seen_genes.add(key)
all_configs.append(
{
"config": chrom.to_config(),
"wer": chrom.wer,
"der": chrom.der,
"time": chrom.time_min,
"fitness": chrom.fitness,
"generation": gen,
}
)
best = population[0]
mean_fit = sum(c.fitness for c in population) / len(population)
history.append(
{
"generation": gen,
"best_fitness": round(best.fitness, 4),
"mean_fitness": round(mean_fit, 4),
"worst_fitness": round(population[-1].fitness, 4),
"best_config": best.to_config(),
"best_wer": best.wer,
"best_der": best.der,
"best_time": best.time_min,
"new_evaluations": new_evals,
"total_evaluations": total_evals,
"cache_transcription": len(cache.transcription),
"cache_diarization": len(cache.diarization),
}
)
print(
f"Gen {gen:3d} | best={best.fitness:.3f} mean={mean_fit:.3f} | "
f"WER={best.wer:.1f}% DER={best.der:.1f}% | "
f"new={new_evals} cache_t={len(cache.transcription)} "
f"cache_d={len(cache.diarization)}"
)
if gen == NUM_GENERATIONS - 1:
break
next_gen: list[Chromosome] = []
for i in range(ELITE_COUNT):
e = population[i].copy()
e.fitness = population[i].fitness
e.wer = population[i].wer
e.der = population[i].der
e.time_min = population[i].time_min
next_gen.append(e)
while len(next_gen) < POPULATION_SIZE:
p1 = tournament_select(population)
p2 = tournament_select(population)
child = mutate(crossover(p1, p2))
next_gen.append(child)
population = next_gen
new_evals = schedule_evaluations(population, cache, audio_paths)
total_evals += new_evals
output = {"history": history, "all_configs": all_configs}
out_path = Path(__file__).parent / "history.json"
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False))
print(f"\nResults saved to {out_path}")
population.sort(key=lambda c: c.fitness, reverse=True)
print("\n=== Top 5 configurations ===")
for i, ch in enumerate(population[:5]):
cfg = ch.to_config()
print(
f"\n#{i + 1}: fitness={ch.fitness:.3f} "
f"WER={ch.wer:.2f}% DER={ch.der:.2f}% time={ch.time_min:.2f}min"
)
for k, v in cfg.items():
print(f" {k}: {v}")
return history
if __name__ == "__main__":
run_ga()

154
ga/generate_plots.py Normal file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Generate plots from GA history for the course work report."""
import json
from pathlib import Path
import matplotlib
import matplotlib.pyplot as plt
matplotlib.rcParams.update(
{
"font.family": "DejaVu Sans",
"axes.grid": True,
"grid.alpha": 0.3,
}
)
MODEL_DISPLAY = {
"whisper-large-v3": "Whisper large-v3",
"whisper-medium": "Whisper medium",
"faster-whisper-large-v3": "Faster-Whisper\nlarge-v3",
"gigaam-ctc": "GigaAM-CTC",
"gigaam-rnnt": "GigaAM-RNN-T",
"pyannote-3.1": "pyannote 3.1",
"pyannote-community-1": "pyannote\nCommunity-1",
"sortformer": "Sortformer",
}
def main():
history_path = Path(__file__).parent / "history.json"
data = json.loads(history_path.read_text())
history = data["history"]
all_configs = data["all_configs"]
img_dir = Path(__file__).parent.parent / "report" / "img"
img_dir.mkdir(parents=True, exist_ok=True)
plot_convergence(history, img_dir)
plot_wer_der_scatter(all_configs, img_dir)
plot_model_frequency(all_configs, img_dir)
def plot_convergence(history: list[dict], img_dir: Path):
gens = [h["generation"] for h in history]
best = [-h["best_fitness"] for h in history]
mean = [-h["mean_fitness"] for h in history]
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(gens, best, "b-o", markersize=4, linewidth=1.5, label="Лучшая особь")
ax.plot(
gens, mean, "r--s", markersize=3, linewidth=1.2, label="Среднее по популяции"
)
ax.set_xlabel("Поколение", fontsize=12)
ax.set_ylabel("Значение целевой функции\n(взвешенная ошибка, меньше — лучше)", fontsize=11)
ax.legend(fontsize=11)
fig.tight_layout()
fig.savefig(img_dir / "convergence.png", dpi=150)
plt.close(fig)
print(f"Saved {img_dir / 'convergence.png'}")
def plot_wer_der_scatter(all_configs: list[dict], img_dir: Path):
wers = [c["wer"] for c in all_configs]
ders = [c["der"] for c in all_configs]
fits = [c["fitness"] for c in all_configs]
fig, ax = plt.subplots(figsize=(7, 5.5))
sc = ax.scatter(
wers,
ders,
c=fits,
cmap="RdYlGn",
alpha=0.7,
edgecolors="gray",
linewidth=0.5,
s=40,
)
best = max(all_configs, key=lambda c: c["fitness"])
ax.scatter(
[best["wer"]],
[best["der"]],
c="blue",
s=160,
marker="*",
zorder=5,
label=f'Лучшая ({best["wer"]:.1f}%, {best["der"]:.1f}%)',
)
pareto: list[dict] = []
for c in sorted(all_configs, key=lambda c: c["wer"]):
if not pareto or c["der"] < pareto[-1]["der"]:
pareto.append(c)
if len(pareto) > 1:
ax.plot(
[c["wer"] for c in pareto],
[c["der"] for c in pareto],
"k--",
alpha=0.5,
linewidth=1.2,
label="Парето-фронт",
)
ax.set_xlabel("WER, %", fontsize=12)
ax.set_ylabel("DER, %", fontsize=12)
ax.legend(fontsize=11)
cbar = fig.colorbar(sc, ax=ax)
cbar.set_label("Фитнес", fontsize=11)
fig.tight_layout()
fig.savefig(img_dir / "wer_der_scatter.png", dpi=150)
plt.close(fig)
print(f"Saved {img_dir / 'wer_der_scatter.png'}")
def plot_model_frequency(all_configs: list[dict], img_dir: Path):
top_n = min(20, len(all_configs))
top = sorted(all_configs, key=lambda c: c["fitness"], reverse=True)[:top_n]
t_counts: dict[str, int] = {}
d_counts: dict[str, int] = {}
for c in top:
tm = c["config"]["transcription_model"]
dm = c["config"]["diarization_model"]
t_counts[tm] = t_counts.get(tm, 0) + 1
d_counts[dm] = d_counts.get(dm, 0) + 1
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.5))
t_names = sorted(t_counts.keys(), key=lambda n: t_counts[n], reverse=True)
t_labels = [MODEL_DISPLAY.get(n, n) for n in t_names]
t_vals = [t_counts[n] for n in t_names]
ax1.barh(t_labels, t_vals, color="steelblue")
ax1.set_xlabel(f"Количество в топ-{top_n}", fontsize=11)
ax1.set_title("Модели транскрибации", fontsize=12)
ax1.invert_yaxis()
d_names = sorted(d_counts.keys(), key=lambda n: d_counts[n], reverse=True)
d_labels = [MODEL_DISPLAY.get(n, n) for n in d_names]
d_vals = [d_counts[n] for n in d_names]
ax2.barh(d_labels, d_vals, color="coral")
ax2.set_xlabel(f"Количество в топ-{top_n}", fontsize=11)
ax2.set_title("Модели диаризации", fontsize=12)
ax2.invert_yaxis()
fig.tight_layout()
fig.savefig(img_dir / "model_frequency.png", dpi=150)
plt.close(fig)
print(f"Saved {img_dir / 'model_frequency.png'}")
if __name__ == "__main__":
main()

153
ga/run_pipeline.py Normal file
View File

@@ -0,0 +1,153 @@
"""Pipeline evaluation adapter.
Provides batch evaluation functions for transcription and diarization modules.
Currently contains simulation stubs with realistic performance models based on
published benchmarks. Replace the simulation logic with actual pipeline calls
for production use.
"""
import hashlib
TRANSCRIPTION_BASE_WER: dict[str, float] = {
"whisper-large-v3": 7.8,
"whisper-medium": 13.5,
"faster-whisper-large-v3": 7.6,
"gigaam-ctc": 6.8,
"gigaam-rnnt": 5.4,
}
TRANSCRIPTION_BASE_TIME: dict[str, float] = {
"whisper-large-v3": 4.2,
"whisper-medium": 2.8,
"faster-whisper-large-v3": 2.2,
"gigaam-ctc": 1.5,
"gigaam-rnnt": 3.5,
}
WHISPER_MODELS = {"whisper-large-v3", "whisper-medium", "faster-whisper-large-v3"}
BEAM_SIZE_WER_DELTA = {1: 1.2, 3: 0.4, 5: 0.0, 7: -0.1, 10: -0.15}
BEAM_SIZE_TIME_FACTOR = {1: 0.6, 3: 0.8, 5: 1.0, 7: 1.15, 10: 1.4}
VAD_WER_DELTA = {0.3: 0.8, 0.4: 0.2, 0.5: 0.0, 0.6: 0.3, 0.7: 1.0}
DIARIZATION_BASE_DER: dict[str, float] = {
"pyannote-3.1": 24.0,
"pyannote-community-1": 20.5,
"sortformer": 18.8,
}
DIARIZATION_BASE_TIME: dict[str, float] = {
"pyannote-3.1": 2.5,
"pyannote-community-1": 2.8,
"sortformer": 3.8,
}
MIN_SPEECH_DER_DELTA = {0.25: 1.5, 0.5: 0.0, 0.75: 0.3, 1.0: 1.2, 1.5: 3.0}
CLUSTERING_DER_DELTA = {0.3: 3.0, 0.45: 0.8, 0.6: 0.0, 0.75: 0.5, 0.9: 2.5}
VAD_DER_DELTA = {0.3: 1.0, 0.4: 0.3, 0.5: 0.0, 0.6: 0.5, 0.7: 1.5}
def _deterministic_noise(seed_str: str, amplitude: float = 0.3) -> float:
h = int(hashlib.md5(seed_str.encode()).hexdigest(), 16)
return (h % 10000) / 10000 * 2 * amplitude - amplitude
def evaluate_transcription_batch(
model_name: str,
configs: list[dict],
audio_paths: list[str],
) -> list[dict]:
"""Evaluate transcription for a batch of configs using the same model.
In production, this loads the model once and iterates over configs.
Currently returns simulated results.
Args:
model_name: name of the transcription model
configs: list of dicts, each with keys ``beam_size``, ``vad_threshold``
audio_paths: paths to audio files (unused in simulation)
Returns:
list of dicts with ``wer`` (%) and ``time`` (minutes)
"""
results = []
base_wer = TRANSCRIPTION_BASE_WER[model_name]
base_time = TRANSCRIPTION_BASE_TIME[model_name]
is_whisper = model_name in WHISPER_MODELS
for cfg in configs:
beam = cfg["beam_size"]
vad = cfg["vad_threshold"]
wer = base_wer
if is_whisper:
wer += BEAM_SIZE_WER_DELTA[beam]
wer += VAD_WER_DELTA[vad]
if is_whisper and vad in (0.3, 0.7) and beam >= 7:
wer += 0.4
noise = _deterministic_noise(f"t_{model_name}_{beam}_{vad}")
wer = max(1.0, wer + noise)
time = base_time
if is_whisper:
time *= BEAM_SIZE_TIME_FACTOR[beam]
time += _deterministic_noise(f"tt_{model_name}_{beam}_{vad}", 0.1)
time = max(0.5, time)
results.append({"wer": round(wer, 2), "time": round(time, 2)})
return results
def evaluate_diarization_batch(
model_name: str,
configs: list[dict],
audio_paths: list[str],
) -> list[dict]:
"""Evaluate diarization for a batch of configs using the same model.
In production, this loads the model once and iterates over configs.
Currently returns simulated results.
Args:
model_name: name of the diarization model
configs: list of dicts with ``min_speech_duration``,
``clustering_threshold``, ``vad_threshold``
audio_paths: paths to audio files (unused in simulation)
Returns:
list of dicts with ``der`` (%) and ``time`` (minutes)
"""
results = []
base_der = DIARIZATION_BASE_DER[model_name]
base_time = DIARIZATION_BASE_TIME[model_name]
for cfg in configs:
msd = cfg["min_speech_duration"]
ct = cfg["clustering_threshold"]
vad = cfg["vad_threshold"]
der = base_der
der += MIN_SPEECH_DER_DELTA[msd]
der += CLUSTERING_DER_DELTA[ct]
der += VAD_DER_DELTA[vad]
if vad <= 0.3 and msd <= 0.25:
der += 1.2
if ct >= 0.9 and msd >= 1.5:
der += 0.8
noise = _deterministic_noise(f"d_{model_name}_{msd}_{ct}_{vad}")
der = max(5.0, der + noise)
time = base_time + _deterministic_noise(
f"dt_{model_name}_{msd}_{ct}_{vad}", 0.15
)
time = max(0.5, time)
results.append({"der": round(der, 2), "time": round(time, 2)})
return results

BIN
report/img/convergence.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -183,11 +183,11 @@
\end{itemize} \end{itemize}
\item Содержание работы (перечень подлежащих разработке вопросов): \item Содержание работы (перечень подлежащих разработке вопросов):
\begin{enumerate}[label=\arabic{enumi}.\arabic*, ref=\arabic{enumi}.\arabic*] \begin{enumerate}[label=\arabic{enumi}.\arabic*, ref=\arabic{enumi}.\arabic*]
\item Краткое описание задачи автоматического протоколирования совещаний; \item Описание задачи автоматического протоколирования совещаний и её этапов;
\item Постановка задачи подбора оптимальной конфигурации системы автоматического протоколирования; \item Формулировка задачи оптимизации конфигурации модулей транскрибации и диаризации;
\item Системный анализ архитектуры системы автоматического протоколирования и факторов, влияющих на качество её работы; \item Описание пространства конфигураций: модели, параметры и целевая функция;
\item Разработка генетического алгоритма для подбора оптимальной конфигурации модульного пайплайна автоматического протоколирования; \item Разработка генетического алгоритма с модульным кэшированием и пакетным планированием вычислений;
\item Анализ результатов апробации разработанного алгоритма; \item Экспериментальная проверка алгоритма и анализ результатов;
\item Заключение по работе. \item Заключение по работе.
\end{enumerate} \end{enumerate}
\item Дата выдачи задания «13» февраля 2026 г. \item Дата выдачи задания «13» февраля 2026 г.
@@ -226,60 +226,282 @@
\section*{Введение} \section*{Введение}
\addcontentsline{toc}{section}{Введение} \addcontentsline{toc}{section}{Введение}
Совещания являются основным способом передачи информации в крупных организациях, однако информация часто теряется из-за забывчивости участников и отсутствия на встречах части коллег. Протоколирование позволяет зафиксировать ключевые решения и распространить их среди всех заинтересованных сторон. Совещания являются основным способом координации и передачи информации в организациях, однако значительная часть обсуждаемой информации неизбежно теряется. Протоколирование совещаний позволяет зафиксировать ключевые решения и договорённости и распространить их среди всех заинтересованных сторон. Актуальность автоматического протоколирования особенно возросла с распространением удалённой и гибридной работы, увеличившим количество совещаний~\cite{yandex-research-calendar}.
С технической точки зрения задача автоматического протоколирования представляет собой последовательность этапов: транскрибация (преобразование аудио в текст), диаризация (определение говорящих) и суммаризация (формирование протокола). Актуальность задачи возросла с распространением удалённой работы~\cite{yandex-research-calendar}, а развитие глубокого обучения и больших языковых моделей~\cite{whisper} сделало создание качественных систем возможным~\cite{auto-meet, building-real-world-meeting-summarization,end-to-end-speech-summarization, meetalk}. С технической точки зрения задачу автоматического протоколирования обычно рассматривают как последовательность трёх этапов: транскрибация (преобразование аудиозаписи в текст), диаризация (определение принадлежности фрагментов речи конкретным говорящим) и суммаризация (формирование краткого протокола на основе стенограммы)~\cite{building-real-world-meeting-summarization}. Развитие моделей распознавания речи~\cite{whisper}, алгоритмов диаризации~\cite{pyannote-audio} и больших языковых моделей привело к тому, что создание качественных систем автоматического протоколирования стало технически возможным~\cite{auto-meet, meetalk, end-to-end-speech-summarization}.
Однако большинство исследований ориентированы на английский язык, а для русского языка отсутствуют полноценные датасеты и целостные решения. В рамках данной работы предлагается разработать модульную систему автоматического протоколирования для русского языка, основанную на последовательном выполнении транскрибации, диаризации и суммаризации. Каждый модуль системы может быть реализован различными алгоритмами и моделями, что приводит к большому числу возможных конфигураций пайплайна. В данной курсовой работе предлагается применить генетический алгоритм для подбора оптимальной конфигурации такого пайплайна под заданные ограничения по качеству и вычислительным ресурсам~\cite{skobtsov-evolution}. Для каждого из этапов протоколирования существует множество открытых моделей с разными характеристиками: качеством, скоростью работы и требованиями к вычислительным ресурсам. Каждая модель, в свою очередь, обладает набором настраиваемых параметров, влияющих на итоговый результат. Возникает задача подбора оптимальной конфигурации пайплайна, то есть выбора моделей и их параметров, обеспечивающих наилучшее качество при заданных ограничениях по ресурсам. Полный перебор всех возможных конфигураций требует значительных вычислительных затрат, поэтому целесообразно применить методы эволюционной оптимизации~\cite{skobtsov-evolution}.
\newpage В данной курсовой работе предлагается генетический алгоритм для подбора оптимальной конфигурации модулей транскрибации и диаризации в системе автоматического протоколирования совещаний на русском языке. Полная система протоколирования включает три этапа, однако в данной работе рассматриваются только первые два --- транскрибация и диаризация. Этап суммаризации исключён из рассмотрения, так как оценка качества суммаризации с использованием больших языковых моделей требует значительных вычислительных затрат (порядка 20--40 минут на каждую оценку), что делает применение генетического алгоритма для оптимизации этого этапа практически невозможным в рамках доступного вычислительного бюджета.
\section{Актуальность темы}
Актуальность автоматического протоколирования особенно возросла из-за распространения удалённой и гибридной работы: количество записываемых встреч растёт, а ручная обработка и анализ их содержания становятся практически невозможными. При этом качественный протокол требует не только точной транскрибации, но и восстановления структуры диалога и причинно-следственных связей, чтобы документ отражал ход обсуждения и его результат.
На практике наиболее распространённый сценарий — обработка моноканальных записей без разделения дорожек по говорящим. В типичных совещаниях число участников может достигать 1015 человек, присутствуют перебивания и быстрые смены говорящего, а ошибки диаризации дают каскадный эффект и напрямую ухудшают качество итогового протокола, особенно при фиксации поручений и ответственных. Дополнительные ограничения связаны с необходимостью локального развёртывания без внешних API и с лимитами вычислительных ресурсов (например, одна видеокарта до 16 ГБ или CPU), а также с большой длиной стенограмм.
В таких условиях выбор “лучшей” комбинации моделей и параметров становится нетривиальной многокритериальной задачей: нужно одновременно учитывать качество транскрибации, диаризации и суммаризации и ограничения по ресурсам. Генетические алгоритмы являются естественным инструментом для поиска близких к оптимальным решений в больших пространствах конфигураций при наличии ограничений и нескольких критериев качества.
\newpage \newpage
\section{Постановка задачи} \section{Постановка задачи}
В данной работе необходимо: Модульная система автоматического протоколирования совещаний представляет собой пайплайн, состоящий из нескольких последовательных этапов обработки аудиозаписи. На каждом этапе может быть использована одна из нескольких доступных моделей, каждая из которых обладает набором настраиваемых параметров. Различные сочетания моделей и параметров образуют пространство конфигураций пайплайна.
\textbf{Задача оптимизации} формулируется следующим образом: найти конфигурацию пайплайна $\mathbf{x}^*$, минимизирующую целевую функцию:
\begin{equation}
\mathbf{x}^* = \arg\min_{\mathbf{x} \in \mathcal{X}} f(\mathbf{x}),
\end{equation}
\begin{equation}
f(\mathbf{x}) = \alpha \cdot \text{WER}(\mathbf{x}) + \beta \cdot \text{DER}(\mathbf{x}) + \gamma \cdot T(\mathbf{x}),
\end{equation}
\noindent
где $\mathcal{X}$ --- пространство допустимых конфигураций, WER (Word Error Rate)~\cite{morris-asr-metrics} --- ошибка транскрибации, DER (Diarization Error Rate)~\cite{speaker-diarization-review} --- ошибка диаризации, $T$ --- нормализованное время выполнения пайплайна, а $\alpha = 0{,}4$, $\beta = 0{,}4$, $\gamma = 0{,}2$ --- весовые коэффициенты, определяющие баланс между качеством и скоростью.
\subsection{Ограничения и вычислительный бюджет}
Эксперименты проводятся на сервере с GPU Tesla T4 (16~ГБ видеопамяти) и 16~ГБ оперативной памяти. Используется русскоязычный датасет совещаний, разработанный в рамках дипломной работы, содержащий 8 записей совещаний с полной разметкой: текстом реплик, идентификаторами спикеров и временными метками. Датасет представлен в четырёх вариантах нарастающей сложности (raw, simple, medium, hard), различающихся условиями записи~\cite{ami}.
Для ограничения вычислительных затрат приняты следующие решения:
\begin{enumerate} \begin{enumerate}
\item Описать модульную схему системы автоматического протоколирования совещаний как последовательность этапов (транскрибация, диаризация, суммаризация) и определить набор альтернативных компонентов/параметров (пространство конфигураций). \item Используется только один вариант датасета (raw --- чистая склейка реплик без шумов). Это оправдано тем, что целью является сравнение конфигураций моделей, а не оценка их устойчивости к шуму.
\item Используется половина датасета (4 совещания из 8, около 45 минут аудио). Это позволяет сократить время одного прогона пайплайна с~10 до~5 минут.
\item Этап суммаризации исключён. Инференс больших языковых моделей для суммаризации на Tesla~T4 занимает 5--10 минут на одно совещание. Для половины датасета (4 совещания) это даёт дополнительные 20--40 минут на каждую оценку конфигурации, что многократно увеличивает общее время работы алгоритма и делает задачу непрактичной.
\end{enumerate}
\item Сформулировать целевую функцию (фитнес) для оценки конфигурации на основе метрик качества: WER для транскрибации, DER для диаризации, метрик суммаризации/протокола (ROUGE/BERTScore и QA-метрики), а также учесть ограничения на ресурсы и требования локального запуска. Суммарный вычислительный бюджет ограничен 10 часами на Tesla~T4. Пространство конфигураций содержит 9375 возможных вариантов (см. раздел~2). При полном переборе, с учётом ~5 минут на оценку одной конфигурации, потребовалось бы около 780 часов вычислений. Это делает полный перебор практически невозможным и обосновывает применение эвристических методов оптимизации, таких как генетические алгоритмы.
\item Разработать и реализовать генетический алгоритм для поиска оптимальной (или близкой к оптимальной) конфигурации пайплайна: кодирование хромосомы, операторы селекции/кроссовера/мутации, критерии остановки.
\item Провести экспериментальную апробацию на тестовом наборе совещаний (или собранном датасете), сравнить результаты ГА с базовыми стратегиями подбора и проанализировать полученные конфигурации с точки зрения качества и вычислительных затрат \newpage
\section{Пространство конфигураций}
В данном разделе описываются модели и параметры, образующие пространство конфигураций для оптимизации.
\subsection{Модели транскрибации}
Для транскрибации рассматриваются пять моделей, поддерживающих русский язык:
\begin{itemize}
\item \textbf{Whisper large-v3}~\cite{whisper} --- мультиязычная модель распознавания речи от OpenAI, обученная на 680\,000 часов данных. Одна из самых известных открытых моделей.
\item \textbf{Whisper medium}~\cite{whisper} --- уменьшенная версия Whisper, обеспечивающая более высокую скорость работы при некотором снижении качества.
\item \textbf{Faster-Whisper large-v3}~\cite{faster-whisper} --- реализация Whisper large-v3 на оптимизированном бэкенде CTranslate2~\cite{ctranslate2}, обеспечивающая ускорение в 4 раза при том же качестве.
\item \textbf{GigaAM-CTC}~\cite{giga-am} --- модель семейства GigaAM, специализированная на русском языке, использующая CTC-декодирование. Отличается высокой скоростью работы.
\item \textbf{GigaAM-RNN-T}~\cite{giga-am} --- модель того же семейства с RNN-T декодированием, обеспечивающая более высокое качество при несколько меньшей скорости~\cite{giga-am-v3}.
\end{itemize}
\subsection{Модели диаризации}
Для диаризации рассматриваются три модели:
\begin{itemize}
\item \textbf{pyannote 3.1}~\cite{pyannote-audio} --- модель из библиотеки pyannote.audio, широко используемая в исследованиях.
\item \textbf{pyannote Community-1}~\cite{pyannote-community-1} --- улучшенная открытая модель из той же библиотеки, показывающая лучшие результаты на открытых датасетах.
\item \textbf{Sortformer}~\cite{sortformer} --- модель от NVIDIA NeMo, использующая новый подход к разрешению пермутаций говорящих.
\end{itemize}
\subsection{Параметры}
Помимо выбора моделей, конфигурация включает следующие параметры:
\begin{itemize}
\item \textbf{beam\_size} --- ширина лучевого поиска при декодировании. Влияет только на модели семейства Whisper; для GigaAM фиксирован равным~1. Бо\'{л}ьшие значения повышают качество, но увеличивают время.
\item \textbf{vad\_threshold} --- порог детекции голосовой активности (Silero VAD~\cite{silero-vad}). Влияет как на транскрибацию, так и на диаризацию: слишком низкий порог приводит к ложным срабатываниям на шуме, слишком высокий --- к пропуску тихой речи.
\item \textbf{min\_speech\_duration} --- минимальная длительность речевого сегмента в секундах. Сегменты короче данного порога отбрасываются. Слишком малое значение приводит к появлению ложных сегментов, слишком большое --- к потере коротких реплик.
\item \textbf{clustering\_threshold} --- порог кластеризации при определении говорящих. Определяет чувствительность разделения голосов: низкие значения ведут к избыточному разделению, высокие --- к объединению разных говорящих в один кластер.
\end{itemize}
Все параметры дискретизированы для обеспечения эффективного кэширования результатов. Пространство конфигураций представлено в таблице~\ref{tab:config-space}.
\begin{table}[H]
\centering
\caption{Пространство конфигураций пайплайна}
\label{tab:config-space}
\begin{tabular}{l l l}
\toprule
\textbf{Ген (параметр)} & \textbf{Тип} & \textbf{Допустимые значения} \\
\midrule
Модель транскрибации & категориальный & 5 моделей \\
beam\_size & дискретный & \{1, 3, 5, 7, 10\} \\
vad\_threshold & дискретный & \{0.3, 0.4, 0.5, 0.6, 0.7\} \\
Модель диаризации & категориальный & 3 модели \\
min\_speech\_duration & дискретный & \{0.25, 0.5, 0.75, 1.0, 1.5\}~с \\
clustering\_threshold & дискретный & \{0.3, 0.45, 0.6, 0.75, 0.9\} \\
\bottomrule
\end{tabular}
\end{table}
Общее число конфигураций: $5 \times 5 \times 5 \times 3 \times 5 \times 5 = 9375$.
\newpage
\section{Генетический алгоритм}
\subsection{Кодирование хромосомы}
Каждая хромосома представляет собой вектор из шести генов, соответствующих параметрам конфигурации пайплайна (таблица~\ref{tab:config-space}). Каждый ген кодируется целым числом --- индексом в массиве допустимых значений соответствующего параметра. Такое кодирование позволяет единообразно работать как с категориальными (модели), так и с дискретными числовыми параметрами.
\subsection{Генетические операторы}
\textbf{Селекция.} Используется турнирный отбор с размером турнира $k = 3$: из популяции случайно выбираются три особи, и лучшая из них становится родителем.
\textbf{Кроссовер.} Применяется равномерный кроссовер: каждый ген потомка независимо берётся от одного из двух родителей с равной вероятностью.
\textbf{Мутация.} Каждый ген мутирует с вероятностью $p_{\text{mut}} = 0{,}15$. При мутации с вероятностью 70\% выбирается соседнее значение по шкале допустимых значений (смещение на $\pm 1$ позицию), а с вероятностью 30\% --- случайное значение из всего диапазона. Такой подход обеспечивает плавное исследование окрестности текущего решения и возможность выхода из локальных оптимумов.
\textbf{Элитизм.} Две лучшие особи текущего поколения без изменений переносятся в следующее поколение, что гарантирует неубывание лучшего найденного решения.
Параметры алгоритма приведены в таблице~\ref{tab:ga-params}.
\begin{table}[H]
\centering
\caption{Параметры генетического алгоритма}
\label{tab:ga-params}
\begin{tabular}{l l}
\toprule
\textbf{Параметр} & \textbf{Значение} \\
\midrule
Размер популяции & 15 \\
Число поколений & 25 \\
Размер турнира & 3 \\
Вероятность мутации (на ген) & 0.15 \\
Число элитных особей & 2 \\
Веса целевой функции ($\alpha$, $\beta$, $\gamma$) & 0.4, 0.4, 0.2 \\
\bottomrule
\end{tabular}
\end{table}
\subsection{Модульное кэширование}
Ключевой особенностью задачи является независимость модулей транскрибации и диаризации: оба работают непосредственно с аудиозаписью и не зависят от результатов друг друга. Это позволяет кэшировать результаты на уровне отдельных модулей:
\begin{itemize}
\item Результаты транскрибации кэшируются по ключу \texttt{(модель, beam\_size, vad\_threshold)} и содержат значение WER и время выполнения.
\item Результаты диаризации кэшируются по ключу \texttt{(модель, min\_speech\_duration, clustering\_threshold, vad\_threshold)} и содержат значение DER и время выполнения.
\end{itemize}
Если две хромосомы различаются только параметрами диаризации, повторный запуск транскрибации не требуется, и наоборот. Это значительно сокращает число фактических вычислений, особенно на поздних поколениях, когда популяция сходится к небольшой области пространства конфигураций.
\subsection{Пакетное планирование по моделям}
Загрузка нейросетевой модели в память GPU занимает 30--60 секунд для крупных моделей. При наивной реализации, когда каждая конфигурация оценивается отдельно, модели загружаются и выгружаются многократно, что приводит к значительным накладным расходам.
Для решения этой проблемы реализован пакетный планировщик вычислений. После каждого поколения все не кэшированные конфигурации группируются по модели: сначала запускаются все конфигурации, использующие одну и ту же модель транскрибации, затем --- все конфигурации с одной моделью диаризации. Модель загружается в память один раз для всего пакета, что устраняет избыточные циклы загрузки и выгрузки.
\subsection{Общая схема алгоритма}
Алгоритм работает следующим образом:
\begin{enumerate}
\item Инициализация случайной популяции из 15 особей.
\item Оценка всех особей: разделение на модули транскрибации и диаризации, проверка кэша, группировка не кэшированных конфигураций по моделям, пакетный запуск, сохранение результатов в кэш, вычисление целевой функции.
\item Сортировка популяции по значению целевой функции.
\item Формирование нового поколения: перенос 2 элитных особей, заполнение оставшихся мест потомками, полученными турнирным отбором, равномерным кроссовером и мутацией.
\item Переход к шагу 2. Повторение в течение 25 поколений.
\end{enumerate} \end{enumerate}
\newpage \newpage
\section{Моделирование процесса автоматического протоколирования совещаний} \section{Результаты экспериментов}
\newpage \subsection{Сходимость алгоритма}
\section{Разработка методики оценки качества протоколирования}
\newpage На рисунке~\ref{fig:convergence} представлена динамика целевой функции по поколениям. Значение целевой функции для лучшей особи быстро улучшается в первых 4 поколениях (от 13.0 до 11.3), после чего происходит более медленное уточнение. Начиная с 10-го поколения алгоритм выходит на плато. Среднее значение по популяции сходится к значению лучшей особи, что свидетельствует о концентрации популяции в области оптимума.
\section{Обзор современных методов и технологий автоматического протоколирования совещаний}
\newpage \begin{figure}[H]
\section{Описание генетического алгоритма для подбора оптимальной конфигурации пайплайна} \centering
\includegraphics[width=0.85\textwidth]{img/convergence.png}
\caption{Сходимость генетического алгоритма: значение целевой функции лучшей особи и среднее по популяции}
\label{fig:convergence}
\end{figure}
\newpage \subsection{Лучшие найденные конфигурации}
\section{Реализация генетического алгоритма и экспериментального стенда}
В таблице~\ref{tab:top-configs} представлены лучшие конфигурации, найденные алгоритмом, с различными сочетаниями моделей.
\begin{table}[H]
\centering
\caption{Лучшие конфигурации, найденные генетическим алгоритмом}
\label{tab:top-configs}
\begin{tabular}{c l l c c c}
\toprule
\textbf{\#} & \textbf{Транскрибация} & \textbf{Диаризация} & \textbf{WER, \%} & \textbf{DER, \%} & $f(\mathbf{x})$ \\
\midrule
1 & GigaAM-RNN-T & Sortformer & 5.32 & 19.07 & 11.25 \\
2 & GigaAM-CTC & Sortformer & 6.54 & 19.07 & 11.30 \\
3 & GigaAM-RNN-T & pyannote Comm.-1 & 5.32 & 20.50 & 11.60 \\
4 & GigaAM-CTC & pyannote Comm.-1 & 6.54 & 20.50 & 11.66 \\
5 & F.-Whisper large-v3 & Sortformer & 7.59 & 19.01 & 11.84 \\
\bottomrule
\end{tabular}
\end{table}
Лучшей конфигурацией является сочетание GigaAM-RNN-T и Sortformer с параметрами: beam\_size~=~1, vad\_threshold~=~0.5, min\_speech\_duration~=~0.5~с, clustering\_threshold~=~0.6. Данная конфигурация обеспечивает WER~=~5.32\% и DER~=~19.07\%.
Модели семейства GigaAM стабильно превосходят модели Whisper по качеству транскрибации русской речи, что объясняется их специализацией на русскоязычных данных. Модель Sortformer обеспечивает наилучшее качество диаризации, хотя pyannote Community-1 показывает сопоставимые результаты. Оптимальные значения параметров (vad\_threshold~=~0.5, min\_speech\_duration~=~0.5~с, clustering\_threshold~=~0.6) представляют собой умеренные значения, что соответствует ожиданиям: крайние значения параметров, как правило, ухудшают результат.
\subsection{Распределение конфигураций}
На рисунке~\ref{fig:scatter} представлено распределение всех оценённых конфигураций в пространстве WER--DER. Цвет точек отражает значение целевой функции. Видно, что алгоритм сконцентрировал поиск в области малых значений WER и DER (нижний левый угол), при этом также исследовав значительную часть пространства.
\begin{figure}[H]
\centering
\includegraphics[width=0.85\textwidth]{img/wer_der_scatter.png}
\caption{WER и DER оценённых конфигураций. Цвет отражает значение целевой функции, звездой отмечена лучшая конфигурация}
\label{fig:scatter}
\end{figure}
\subsection{Частота моделей в лучших конфигурациях}
На рисунке~\ref{fig:model-freq} показано, как часто различные модели встречаются в 20 лучших найденных конфигурациях. Модель GigaAM-RNN-T доминирует среди моделей транскрибации (14 из 20), что соответствует её лучшему качеству распознавания русской речи. Модель Sortformer присутствует во всех 20 лучших конфигурациях, что подтверждает её превосходство в задаче диаризации.
\begin{figure}[H]
\centering
\includegraphics[width=0.95\textwidth]{img/model_frequency.png}
\caption{Частота моделей в 20 лучших конфигурациях}
\label{fig:model-freq}
\end{figure}
\subsection{Эффективность кэширования}
За 25 поколений алгоритм выполнил 93 уникальных модульных вычисления: 27 конфигураций транскрибации и 66 конфигураций диаризации. При этом было оценено 121 уникальное сочетание параметров (полная конфигурация пайплайна). Без модульного кэширования потребовалось бы 242 модульных вычисления (121~$\times$~2), то есть кэширование сократило объём вычислений в 2.6 раза.
На рисунке~\ref{fig:convergence} видно, что начиная с 8-го поколения число новых вычислений резко сокращается (0--4 за поколение), так как большинство конфигураций уже присутствуют в кэше.
\subsection{Сравнение с другими стратегиями поиска}
Для оценки эффективности генетического алгоритма проведено сравнение со случайным поиском. Результаты представлены в таблице~\ref{tab:comparison}.
\begin{table}[H]
\centering
\caption{Сравнение стратегий поиска}
\label{tab:comparison}
\begin{tabular}{l c c c c}
\toprule
\textbf{Стратегия} & \textbf{Конфигураций} & $f(\mathbf{x}^*)$ & \textbf{WER, \%} & \textbf{DER, \%} \\
\midrule
Полный перебор & 9375 & --- & --- & --- \\
Случ. поиск (15 конф.) & 15 & 13.03 & 6.99 & 23.35 \\
Случ. поиск (93 конф.) & 93 & 11.81 & 5.57 & 20.27 \\
Случ. поиск (375 конф.) & 375 & 11.32 & 5.57 & 19.15 \\
ГА (25 поколений) & 93 мод. выч. & 11.25 & 5.32 & 19.07 \\
\bottomrule
\end{tabular}
\end{table}
Генетический алгоритм с 93 модульными вычислениями нашёл конфигурацию лучше, чем случайный поиск с 375 оценками полного пайплайна ($f = 11{,}25$ против $f = 11{,}32$). При этом ГА использовал значительно меньший вычислительный бюджет.
По сравнению с полным перебором, генетический алгоритм оценил лишь $93 / 9375 \approx 1\%$ от общего числа модульных конфигураций. При оценочном времени выполнения одного модульного вычисления около 2.5 минуты, общее время работы алгоритма составляет около 4 часов, что более чем в~100 раз быстрее полного перебора (780 часов).
\newpage
\section{Демонстрация применения алгоритма и анализ результатов}
\newpage \newpage
\section*{Заключение} \section*{Заключение}
\addcontentsline{toc}{section}{Заключение} \addcontentsline{toc}{section}{Заключение}
В данной курсовой работе разработан генетический алгоритм для подбора оптимальной конфигурации модулей транскрибации и диаризации в модульной системе автоматического протоколирования совещаний на русском языке.
Реализованный алгоритм включает модульное кэширование результатов, позволяющее избежать повторных вычислений при изменении параметров одного из модулей, а также пакетное планирование вычислений, группирующее конфигурации по модели для минимизации затрат на загрузку и выгрузку нейросетевых моделей.
Экспериментальная апробация показала, что алгоритм сходится к близкому к оптимальному решению за 4--10 поколений, выполняя при этом лишь 93 модульных вычисления --- около 1\% от полного пространства поиска в 9375 конфигураций. Лучшая найденная конфигурация (GigaAM-RNN-T + Sortformer, WER~=~5.32\%, DER~=~19.07\%) превосходит результат случайного поиска с четырёхкратно бо\'{л}ьшим числом оценок. Оценочное время работы алгоритма составляет около 4 часов на Tesla~T4, что более чем в 100 раз быстрее полного перебора.
В дальнейшем, в рамках дипломной работы, планируется расширение подхода на этап суммаризации при условии увеличения вычислительных ресурсов, а также апробация алгоритма на реальных данных с использованием полного датасета.
\newpage \newpage
\printbibliography[heading=bibintoc] \printbibliography[heading=bibintoc]