Датасет и обучение: класс «люб. дубляж», раскладка многотиповых строк, язык из «Аудио», строки из «Аудио», потолок повторов, веса классов
docker / build (push) Successful in 1m40s
docker / build (push) Successful in 1m40s
- harvest_core: дубляж — тип, а не часть имени студии; split_multi_rows; audio_only_rows; kinozal сопоставляет все аудиодорожки - prepare_data: --max-dup, отсев остатков многотиповых строк - train: веса классов (корень из обратной частоты, потолок 4), --no-class-weights - исправлены регулярки с \b, записанные как символ BS (predict.py, audio18.py) - PARSE_VERSION 3: кэш разбора сбрасывается - docs/dataset.md, docs/api-reference.md обновлены Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e648415719
commit
05537ac9e9
10 files changed
+233
-23
No files matched your search
@@ -86,7 +86,12 @@
|
||||
|
||||
Поле `lang` описывает только язык дорожки; `role` и `lang` не зависят от `translation_type`.
|
||||
|
||||
### translation_type: 14 классов
|
||||
### translation_type: 14 классов (15 после переобучения на пересобранных данных)
|
||||
|
||||
> После пересборки датасета (см. [dataset.md](dataset.md)) добавляется класс `люб. дубляж` — любительский
|
||||
> дубляж; модели, обученные раньше, его не возвращают и по-прежнему отдают 14 классов из списка ниже.
|
||||
> Источник истины для конкретной модели — `GET /health` → `classes`; клиенту безопасно сопоставлять по подстроке
|
||||
> (`дубляж` → дубляж), как описано ниже: `люб. дубляж` её содержит.
|
||||
|
||||
Точные строки (регистр, пробелы и точки после префикса — как есть; сортировка — по алфавиту, так их
|
||||
возвращает `GET /health` → `classes`):
|
||||
@@ -109,6 +114,7 @@
|
||||
| Значение | Кто озвучивал | Вид |
|
||||
|---|---|---|
|
||||
| `проф. дубляж` | профессиональная студия | дубляж (речь актёров заменяет оригинал; в страницах «Дублированный») |
|
||||
| `люб. дубляж` | любительская озвучка | дубляж/псевдодубляж; только в моделях, обученных после пересборки датасета |
|
||||
| `проф. многоголосый` | профессиональная студия | закадровый, три и более голоса |
|
||||
| `проф. двухголосый` | профессиональная студия | закадровый, два голоса |
|
||||
| `проф. одноголосый` | профессиональная студия | закадровый, один голос |
|
||||
|
||||
+27
-6
@@ -10,10 +10,13 @@
|
||||
регулярками** — модель учится воспроизводить эту разметку и обобщать её на
|
||||
новые формулировки.
|
||||
|
||||
Снимок: 3286 строк, 3 источника — rutracker 1660, nnm 1549, kinozal 77.
|
||||
После подготовки: 3284 строки + 458 аугментированных морфологических
|
||||
вариантов («многоголосое», «дублированное» и т.п.), всего 3742 обучающих
|
||||
примера. Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке
|
||||
Размер (на момент последней сборки): ~41 тыс. строк из ~28 тыс. релизов, 3 источника
|
||||
(rutracker, nnm, kinozal); HTML-дамп страниц — `data/raw` (~32 тыс. файлов, ≈2.6 ГБ, в git не
|
||||
хранится), разобранные тексты — `data/pages`. Точные числа показывает «Статистика датасета» в
|
||||
`tools/collect.py` (пункт 3) и отчёт `reports/data_audit.md` после `prepare_data`.
|
||||
Ранние цифры этого документа (3286 строк) относились к первому снимку.
|
||||
К строкам при подготовке добавляются аугментированные морфологические варианты
|
||||
(«многоголосое», «дублированное» и т.п.). Файлы `*_entries.csv` / `*_releases.csv` — те же данные в нарезке
|
||||
по источникам и по релизам; модель используется только `labels.csv`.
|
||||
|
||||
## Колонки labels.csv
|
||||
@@ -40,9 +43,11 @@
|
||||
`raw` берётся `role_raw` конкретной дорожки, чтобы одинаковый текст не
|
||||
получал разные метки).
|
||||
|
||||
## Семантика translation_type (14 классов)
|
||||
## Семантика translation_type (15 классов после пересборки; 14 в ранних моделях)
|
||||
|
||||
Составные метки «кто делал × сколько голосов»:
|
||||
Составные метки «кто делал × сколько голосов». Набор классов не зашит в код: `prepare_data` берёт его
|
||||
из данных (`label_maps.json`), поэтому модель, обученная до пересборки, знает 14 классов, а после —
|
||||
ещё и `люб. дубляж`.
|
||||
|
||||
* префикс: `проф.` (профессиональная), `люб.` (любительская), `авт.`
|
||||
(авторская), без префикса — в тексте не указано;
|
||||
@@ -52,6 +57,22 @@
|
||||
Все «*неизвестно»-варианты объединены в `неизвестно`; типы «оригинал» и
|
||||
«субтитры» (1–2 строки-проскока генератора) удалены.
|
||||
|
||||
### Что изменено в разметке (парсеры `tools/harvest/harvest_core.py`, `PARSE_VERSION = 3`)
|
||||
|
||||
| Проблема в старых метках | Исправление |
|
||||
|---|---|
|
||||
| Любительский дубляж («Любительский (дублированный) Red Head Sound», «псевдодублированный») размечался как `проф. дубляж` (644 из 720 строк) | Новый класс `люб. дубляж` |
|
||||
| Дубляж по имени студии: «многоголосый … Дубляжная», «Продубляж» получали тип «дубляж» | «дубляж» считается типом, только если это отдельное слово или «дублир…» |
|
||||
| Несколько дорожек в одной строке с одной меткой («Профессиональный (двухголосый закадровый), авторский (одноголосый закадровый)»; 7.3% строк) | `split_multi_rows`: строка раскладывается на дорожки теми же правилами, что и сервис (`src/html_extract._split_entries`); «Многоголосый, профессиональный - LostFilm» остаётся одной дорожкой. Остатки старых данных `prepare_data` отбрасывает (счётчик в аудите) |
|
||||
| Язык почти не размечался (122 строки `UKR`, 47 `ENG`): kinozal сопоставлял «Перевод» только с русскими аудиодорожками, а в строках `\|Дубляж, Le Doyen\|` студия читалась как одно имя | Сопоставление со всеми аудиодорожками; язык берётся из строки «Аудио»; `src/trackinfo.parse_audio_line` делит `\|Тип, Студия\|` |
|
||||
| Дорожки, названные только в строках «Аудио» (украинский дубляж и т.п.) терялись | `audio_only_rows`: строки с блоком `\|…\|`, студией и определимым типом (оригинал, комментарии, источники вроде «Blu-ray CEE» не берутся) |
|
||||
| Одни и те же короткие строки доминировали («Перевод: Дублированный» — 2727 копий) | `prepare_data --max-dup` (по умолчанию 40 копий одного текста и типа) |
|
||||
| Дисбаланс классов | Веса классов в потерях типа: корень из обратной частоты, потолок 4 (`--no-class-weights` отключает; значение записывается в `run.json`) |
|
||||
|
||||
Для применения к уже собранным данным нужна переиндексация с пересчётом меток
|
||||
(`python tools/collect.py` → пункт 2 → «Переиндексировать data/raw … y»): старые строки
|
||||
`labels.csv` заменяются, копия прежнего файла сохраняется как `data/labels.bak_<время>.csv`.
|
||||
|
||||
## Аудит (reports/data_audit.md) и что с находками сделано
|
||||
|
||||
| Находка | Обработка |
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ def _norm(s: str) -> str:
|
||||
|
||||
|
||||
RE_CODEC = re.compile(r"(E-?AC-?3|[AА][CС]-?3|AAC|DTS-?HD|DTS|FLAC|TrueHD|Opus|MP3)", re.I)
|
||||
RE_CH = re.compile(r"(\d(?:\.\d)?)\s*ch", re.I)
|
||||
RE_CH = re.compile(r"(\d(?:\.\d)?)\s*ch\b", re.I)
|
||||
RE_KHZ = re.compile(r"(\d+(?:\.\d+)?)\s*kHz", re.I)
|
||||
RE_KBPS = re.compile(r"(\d+)\s*kbps", re.I)
|
||||
|
||||
|
||||
+6
-1
@@ -46,6 +46,11 @@ class DubTagger(nn.Module):
|
||||
self.n_bio = n_bio
|
||||
self.span_weight = span_weight
|
||||
self.age_weight = age_weight
|
||||
self.register_buffer("type_weights", None, persistent=False) # только для обучения; в model.pt не пишется
|
||||
|
||||
def set_type_weights(self, weights) -> None:
|
||||
"""Веса классов для потерь типа (редкие классы весят больше); None — без весов."""
|
||||
self.type_weights = None if weights is None else weights.to(self.type_head.weight.device)
|
||||
|
||||
def forward(self, input_ids, attention_mask, token_type_ids=None,
|
||||
type_labels=None, bio_labels=None, age_labels=None,
|
||||
@@ -63,7 +68,7 @@ class DubTagger(nn.Module):
|
||||
|
||||
loss = None
|
||||
if type_labels is not None:
|
||||
loss = F.cross_entropy(type_logits, type_labels)
|
||||
loss = F.cross_entropy(type_logits, type_labels, weight=self.type_weights)
|
||||
if bio_labels is not None:
|
||||
loss = loss + self.span_weight * F.cross_entropy(
|
||||
bio_logits.reshape(-1, self.n_bio),
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ from .trackinfo import lang_of_text, model_text, role_of
|
||||
# мусор, который модель иногда принимает за автора: раскладка каналов, кодеки
|
||||
RE_NOT_AUTHOR = re.compile(
|
||||
r"(?i)^[\s,/:+.\d]*(?:[LRC]|Ls|Rs|LFE|l|r|Front|Side|ch)?(?:[\s,/:+.\d]*(?:[LRC]|Ls|Rs|LFE|l|r|Front|Side|ch))*[\s,/:+.\d]*$"
|
||||
r"|dolby|digital|audio|kbps|khz|AC-?3|DTS|AAC|Opus")
|
||||
r"|dolby|digital|audio|kbps|khz|\bAC-?3\b|\bDTS\b|\bAAC\b|\bOpus\b")
|
||||
|
||||
|
||||
def refine_span(text: str, span: tuple[int, int] | None) -> tuple[int, int] | None:
|
||||
|
||||
+30
-3
@@ -32,7 +32,7 @@ import re
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from .html_extract import RE_LINE_PREFIX
|
||||
from .html_extract import RE_LINE_PREFIX, RE_TYPE_HINT, _split_entries
|
||||
from .manual import REPEAT, manual_ids
|
||||
from .studio_norm import JUNK_STUDIO_RE, find_studio_span
|
||||
from .trackinfo import context, model_text, parse_audio_line
|
||||
@@ -45,6 +45,7 @@ UNKNOWN_TYPES = {"неизвестно", "авт. неизвестно", "про
|
||||
DROP_TYPES_RE = re.compile(r"оригинал|субтитры")
|
||||
NO_STUDIO = "(не указана)"
|
||||
MIN_LANG_ROWS = 20 # язык с меньшим числом примеров не становится классом
|
||||
MAX_DUP = 40 # одинаковых (текст, тип) в обучении не больше: «Перевод: Дублированный» встречается тысячи раз
|
||||
|
||||
# лёгкая аугментация: морфологические варианты типовых слов (средний род и
|
||||
# пр.), которых в данных мало («Озвучивание: Дублированное | Official»)
|
||||
@@ -93,6 +94,8 @@ def main() -> None:
|
||||
ap.add_argument("--out", default="data/processed")
|
||||
ap.add_argument("--test-size", type=float, default=0.2)
|
||||
ap.add_argument("--seed", type=int, default=42)
|
||||
ap.add_argument("--max-dup", type=int, default=MAX_DUP,
|
||||
help="сколько копий одинакового (текст, тип) оставлять; 0 — не ограничивать")
|
||||
args = ap.parse_args()
|
||||
|
||||
out_dir = Path(args.out)
|
||||
@@ -106,13 +109,19 @@ def main() -> None:
|
||||
|
||||
# --- 1..4: текст, тип, автор, спан --------------------------------
|
||||
rows = []
|
||||
dropped_empty = dropped_junk = dropped_type = 0
|
||||
dropped_empty = dropped_junk = dropped_type = dropped_multi = 0
|
||||
span_misses: list[tuple[str, str, str]] = []
|
||||
for r in raw_rows:
|
||||
text = build_text(r) # без хвоста «Аудио»: спан автора ищем в чистом тексте дорожки
|
||||
if not text:
|
||||
dropped_empty += 1
|
||||
continue
|
||||
# несколько дорожек в одной строке при одной метке — неоднозначный пример (после переиндексации
|
||||
# такие строки уже разложены сборщиком; здесь отсекаются остатки старых данных)
|
||||
nested = [p for p in _split_entries(text) if RE_TYPE_HINT.search(p)]
|
||||
if len(nested) >= 2 and not (r.get("source") == "nnm" and r.get("studio_source") == "tech"):
|
||||
dropped_multi += 1
|
||||
continue
|
||||
ttype = (r.get("translation_type") or "").strip()
|
||||
if ttype in UNKNOWN_TYPES:
|
||||
ttype = "неизвестно"
|
||||
@@ -201,6 +210,23 @@ def main() -> None:
|
||||
drop.update(idxs[1:]) # оставляем первую строку
|
||||
rows = [r for i, r in enumerate(rows) if i not in drop]
|
||||
|
||||
# --- 5б: потолок повторов (дубли одного текста всё равно лежат в одной группе сплита) -----------
|
||||
n_capped = 0
|
||||
if args.max_dup:
|
||||
seen: Counter = Counter()
|
||||
kept = []
|
||||
for r in rows:
|
||||
if r["release_id"] in manual:
|
||||
kept.append(r)
|
||||
continue
|
||||
k = (norm_text(r["text"]), r["translation_type"])
|
||||
seen[k] += 1
|
||||
if seen[k] <= args.max_dup:
|
||||
kept.append(r)
|
||||
else:
|
||||
n_capped += 1
|
||||
rows = kept
|
||||
|
||||
# --- 6: группы (релиз + одинаковый текст) -------------------------
|
||||
uf = UnionFind(len(rows))
|
||||
by_release: dict[tuple[str, str], list[int]] = defaultdict(list)
|
||||
@@ -271,7 +297,8 @@ def main() -> None:
|
||||
f"групп (релиз ∪ одинаковый текст): {len(roots)}",
|
||||
f"удалено: пустой текст {dropped_empty}, мусорный studio {dropped_junk}, "
|
||||
f"не-озвучка (оригинал/субтитры) {dropped_type}, "
|
||||
f"противоречивые дубли {len(drop)} (групп-противоречий: {len(contradictions)})",
|
||||
f"противоречивые дубли {len(drop)} (групп-противоречий: {len(contradictions)}), "
|
||||
f"несколько дорожек в одной строке {dropped_multi}, лишние повторы (> {args.max_dup} одинаковых) {n_capped}",
|
||||
f"спан автора найден: {with_studio} ({with_studio / max(len(rows), 1):.1%}), "
|
||||
f"не найден: {n_span_miss}",
|
||||
f"аугментировано морфологических вариантов: {n_aug}",
|
||||
|
||||
+28
-3
@@ -171,8 +171,25 @@ def evaluate(model, loader, device, id2type, per_row: list | None = None) -> dic
|
||||
}
|
||||
|
||||
|
||||
def class_weights(rows, type2id: dict[str, int], power: float = 0.5) -> torch.Tensor:
|
||||
"""Веса классов типа: (N / (K · n_c)) ** power, нормированные так, что средний вес по примерам = 1.
|
||||
|
||||
power 0.5 (корень из обратной частоты) смягчает дисбаланс, не раздувая шум редчайших классов
|
||||
(у «авт. многоголосый» несколько строк): полная обратная частота дала бы им вес в сотни раз больше.
|
||||
Потолок веса — 4.
|
||||
"""
|
||||
counts = np.ones(len(type2id))
|
||||
for r in rows:
|
||||
counts[type2id[r["translation_type"]]] += 1
|
||||
w = (counts.sum() / (len(counts) * counts)) ** power
|
||||
w = w / (w * counts).sum() * counts.sum()
|
||||
w = np.minimum(w, 4.0) # потолок: классы из единиц строк не должны перетягивать обучение
|
||||
return torch.tensor(w, dtype=torch.float32)
|
||||
|
||||
|
||||
def train_model(name: str, cfg: dict, train_rows, val_rows, test_rows,
|
||||
type2id, device, epochs=None, seed=SEED, log_prefix="", lang2id=None):
|
||||
type2id, device, epochs=None, seed=SEED, log_prefix="", lang2id=None,
|
||||
use_class_weights: bool = True):
|
||||
"""Обучение с early stopping на val; возвращает (модель, токенизатор, отчёт)."""
|
||||
set_seed(seed)
|
||||
tokenizer = AutoTokenizer.from_pretrained(cfg["backbone"])
|
||||
@@ -186,6 +203,8 @@ def train_model(name: str, cfg: dict, train_rows, val_rows, test_rows,
|
||||
val_loader = DataLoader(Rows(val_rows), batch_size=128, collate_fn=collate)
|
||||
test_loader = DataLoader(Rows(test_rows), batch_size=128, collate_fn=collate)
|
||||
id2type = {i: t for t, i in type2id.items()}
|
||||
if use_class_weights:
|
||||
model.set_type_weights(class_weights(train_rows, type2id))
|
||||
|
||||
epochs = epochs or cfg["epochs"]
|
||||
steps = len(train_loader) * epochs
|
||||
@@ -248,6 +267,8 @@ def main() -> None:
|
||||
ap.add_argument("--lr", type=float, default=None, help="заменяет lr профиля")
|
||||
ap.add_argument("--batch", type=int, default=None, help="заменяет batch профиля")
|
||||
ap.add_argument("--seed", type=int, default=SEED)
|
||||
ap.add_argument("--no-class-weights", action="store_true",
|
||||
help="потери типа без весов классов (по умолчанию: корень из обратной частоты)")
|
||||
ap.add_argument("--runs", action="store_true",
|
||||
help="каждая модель — отдельный запуск в --runs-dir; models/best, reports/final_* "
|
||||
"и summary.md не трогаются (выбор лучшей — tools/collect.py, пункт 7)")
|
||||
@@ -293,7 +314,8 @@ def main() -> None:
|
||||
va = [r for r in train_rows if r["fold"] == f]
|
||||
_, _, res = train_model(name, cfg, tr, va, va, type2id, device,
|
||||
epochs=args.epochs_override,
|
||||
seed=args.seed + f, log_prefix=f"cv{f} ", lang2id=lang2id)
|
||||
seed=args.seed + f, log_prefix=f"cv{f} ", lang2id=lang2id,
|
||||
use_class_weights=not args.no_class_weights)
|
||||
cv_metrics.append(res["test"])
|
||||
cv = ({k: float(np.mean([m[k] for m in cv_metrics])) for k in cv_metrics[0]}
|
||||
if cv_metrics else None)
|
||||
@@ -308,7 +330,8 @@ def main() -> None:
|
||||
va = [r for r in train_rows if r["group_id"] in val_groups]
|
||||
model, tokenizer, res = train_model(
|
||||
name, cfg, tr, va, test_rows, type2id, device,
|
||||
epochs=args.epochs_override, seed=args.seed, lang2id=lang2id)
|
||||
epochs=args.epochs_override, seed=args.seed, lang2id=lang2id,
|
||||
use_class_weights=not args.no_class_weights)
|
||||
res["cv_mean"] = cv
|
||||
res["train_seconds"] = round(time.time() - t0, 1)
|
||||
per_row = res.pop("per_row")
|
||||
@@ -319,6 +342,7 @@ def main() -> None:
|
||||
save_model(model, tokenizer, run_dir)
|
||||
shutil.copy(maps_path, run_dir / "label_maps.json")
|
||||
info = {"name": name, **cfg, "max_len": MAX_LEN, "seed": args.seed, "folds": args.folds,
|
||||
"class_weights": not args.no_class_weights,
|
||||
"created": time.strftime("%Y-%m-%d %H:%M:%S"), "fingerprint": fp,
|
||||
"rows": {"train": len(train_rows), "test": len(test_rows)},
|
||||
"final": {k: v for k, v in res.items() if k != "history"}}
|
||||
@@ -338,6 +362,7 @@ def main() -> None:
|
||||
shutil.copy(maps_path, model_dir)
|
||||
(model_dir / "train_config.json").write_text(json.dumps(
|
||||
{"name": name, **cfg, "max_len": MAX_LEN, "seed": args.seed,
|
||||
"class_weights": not args.no_class_weights,
|
||||
"final": {k: v for k, v in res.items() if k != "history"}},
|
||||
ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
runs.store_eval(model_dir, fp, {"metrics": res["test"], "per_row": per_row, "n_unscorable": 0})
|
||||
|
||||
@@ -855,6 +855,38 @@ check("prepare_data: вход модели несёт строку «Аудио
|
||||
and all(r["text"][r["span"][0]:r["span"][1]].startswith("Студия") for r in _ds if r["span"]))
|
||||
check("prepare_data: у редкого языка метка пустая", [r["lang"] for r in _ds if r["release_id"] == "p1"] == [None])
|
||||
|
||||
print("== метки: тип, раскладка, строки из «Аудио», веса классов ==")
|
||||
import harvest_core as _hc2 # noqa: E402
|
||||
_tt = _hc2.translation_type
|
||||
check("тип: любительский дубляж — отдельный класс", _tt("Любительский (дублированный) Red Head Sound")[0] == "люб. дубляж")
|
||||
check("тип: псевдодубляж -> люб. дубляж", _tt("Любительский (псевдодублированный)")[0] == "люб. дубляж")
|
||||
check("тип: обычный дубляж остаётся проф.", _tt("Профессиональный (дублированный) Продубляж")[0] == "проф. дубляж")
|
||||
check("тип: «Дубляжная» — имя студии, а не дубляж",
|
||||
_tt("Любительский (многоголосый закадровый) Дубляжная")[0] == "люб. многоголосый")
|
||||
check("тип: слово «дубляж» отдельно — дубляж", _tt("Дубляж, HDRezka")[0] == "проф. дубляж")
|
||||
_base = {"source": "kinozal", "release_id": "1", "ru_title": "x", "year": "", "is_series": False, "studio": "(не указана)",
|
||||
"translation_type": "проф. многоголосый", "professional": "проф.", "role_raw": "", "age_18": 0,
|
||||
"studio_source": "release", "note": ""}
|
||||
_sp = _hc2.split_multi_rows([{**_base, "raw": "Перевод: Профессиональный (двухголосый закадровый), авторский (одноголосый закадровый)"}])
|
||||
check("раскладка: две дорожки из одной строки", [r["translation_type"] for r in _sp] == ["проф. двухголосый", "авт. одноголосый"],
|
||||
str([(r["translation_type"], r["raw"]) for r in _sp]))
|
||||
_one = _hc2.split_multi_rows([{**_base, "raw": "Перевод: Многоголосый, профессиональный - LostFilm"}])
|
||||
check("раскладка: одна дорожка не режется", len(_one) == 1, str(len(_one)))
|
||||
_tech = _hc2.split_multi_rows([{**_base, "studio_source": "tech", "role_raw": "Многоголосый", "raw": "Перевод: A, B"}])
|
||||
check("раскладка: nnm tech-строка не режется", len(_tech) == 1)
|
||||
_ao = _hc2.audio_only_rows({"id": "2"}, (
|
||||
"Аудио 1: Russian E-AC3 / 5.1 / 48 kHz / 768 kbps |MVO, AlexFilm|\n"
|
||||
"Аудио 2: Ukrainian E-AC3 / 5.1 / 48 kHz / 256 kbps |Дубляж, Le Doyen|\n"
|
||||
"Аудио 3: Russian DTS, 3/2 (L,C,R,l,r) + LFE ch, 640 kbps |Дубляж, Blu-ray CEE|\n"
|
||||
"Аудио 4: English E-AC3 / 5.1 / 48 kHz / 768 kbps |Original|\n"),
|
||||
[{**_base, "studio": "AlexFilm", "raw": "Профессиональный (многоголосый закадровый) AlexFilm"}])
|
||||
check("строки из «Аудио»: украинский дубляж добавлен, оригинал/источник/уже известные — нет",
|
||||
[(r["studio"], r["lang"], r["translation_type"]) for r in _ao] == [("Le Doyen", "UKR", "проф. дубляж")],
|
||||
str([(r["studio"], r["lang"], r["translation_type"]) for r in _ao]))
|
||||
from src.train import class_weights # noqa: E402
|
||||
_cw = class_weights([{"translation_type": t} for t in ["a"] * 1000 + ["b"] * 100 + ["c"] * 4], {"a": 0, "b": 1, "c": 2})
|
||||
check("веса классов: редкие тяжелее, потолок 4", _cw[0] < _cw[1] < _cw[2] <= 4.0, str(_cw))
|
||||
|
||||
print("== зависания сбора ==")
|
||||
import json as _js5, threading as _th5, time as _tm5 # noqa: E402
|
||||
|
||||
|
||||
+1
-1
@@ -1028,7 +1028,7 @@ def cmd_build(rewrite: bool = False, relabel: bool = False, runner: "Runner | No
|
||||
|
||||
# Версия разбора: поднимите при правке парсеров (harvest_core, html_to_text) — тогда
|
||||
# кэш «уже разобрано» сбросится сам и данные пересоберутся один раз.
|
||||
PARSE_VERSION = 2
|
||||
PARSE_VERSION = 3
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
|
||||
@@ -48,6 +48,7 @@ _REPO = Path(__file__).resolve().parents[2]
|
||||
if str(_REPO) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO))
|
||||
from src.audio18 import audio_counts, audio_lines # noqa: E402
|
||||
from src.html_extract import RE_LINE_PREFIX, RE_TYPE_HINT, _split_entries # noqa: E402
|
||||
from src.trackinfo import audio_lines_all, lang_of_text, pick_for_row # noqa: E402
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
@@ -1029,7 +1030,9 @@ STUDIO_NOISE = re.compile(
|
||||
r"\b(закадров\w*|многоголос\w*|двухголос\w*|одноголос\w*|"
|
||||
r"профессиональн\w*|любительск\w*|авторск\w*|дублированн\w*|"
|
||||
r"неофициальн\w*|официальн\w*|полный|перевод|роли озвучивали)\b", re.I)
|
||||
ROLE_PATTERNS = [(r"дублир|дубляж", "дубляж"), (r"многоголос", "многоголосый"),
|
||||
# «дубляж» как тип, а не часть имени студии («Дубляжная», «Продубляж», «Дубляж-Студия»)
|
||||
RE_DUB_TYPE = r"дублир|(?<![а-яё])дубляж(?![а-яё])"
|
||||
ROLE_PATTERNS = [(RE_DUB_TYPE, "дубляж"), (r"многоголос", "многоголосый"),
|
||||
(r"двухголос", "двухголосый"), (r"одноголос", "одноголосый"),
|
||||
(r"субтитр", "субтитры"), (r"оригинал", "оригинал")]
|
||||
|
||||
@@ -1094,6 +1097,8 @@ def translation_type(text: str) -> tuple[str, str]:
|
||||
kind = name
|
||||
break
|
||||
if kind == "дубляж":
|
||||
if re.search(r"любительск|псевдо", t): # любительский дубляж — отдельный класс, не «проф.»
|
||||
return "люб. дубляж", "люб."
|
||||
return "проф. дубляж", "проф."
|
||||
if "профессиональн" in t:
|
||||
pref = "проф."
|
||||
@@ -1243,7 +1248,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
if t["type"] in ("оригинал", "субтитры"):
|
||||
continue
|
||||
match = None
|
||||
for j, a in enumerate(ru_audio):
|
||||
for j, a in enumerate(audios): # по имени студии — среди всех языков (украинский дубляж и т.п.)
|
||||
if j in used:
|
||||
continue
|
||||
tag, st = a["tag"].lower(), t["studio"].lower()
|
||||
@@ -1251,8 +1256,8 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
match = (j, a)
|
||||
break
|
||||
if match is None:
|
||||
for j, a in enumerate(ru_audio):
|
||||
if j not in used and not a["tag"]:
|
||||
for j, a in enumerate(audios): # без имени — только безымянная русская дорожка
|
||||
if j not in used and not a["tag"] and a["lang"].startswith("RU"):
|
||||
match = (j, a)
|
||||
break
|
||||
if match:
|
||||
@@ -1270,7 +1275,7 @@ def _kz_parse_core(rec: dict) -> tuple[list[dict], dict]:
|
||||
"codec": a.get("codec", ""), "channels": a.get("channels", ""),
|
||||
"khz": a.get("khz", ""), "kbps": a.get("kbps", ""),
|
||||
"subtitles": subs, "studio_source": studio_source, "raw": t["line"],
|
||||
"lang": "RUS" if match else "", # в сопоставление идут только русские аудиодорожки
|
||||
"lang": (a.get("lang") or "RUS") if match else "",
|
||||
})
|
||||
rel = {"source": "kinozal", "release_id": rec["id"], "ru_title": info["ru_title"],
|
||||
"year": info["year"], "quality": rows[0]["quality"] if rows else "",
|
||||
@@ -1622,13 +1627,102 @@ def kz_audio_only_rows(rec: dict, text: str) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def split_multi_rows(rows: list[dict]) -> list[dict]:
|
||||
"""Строка с несколькими дорожками («Проф. многоголосый, авторский (одноголосый)») -> по строке на дорожку.
|
||||
|
||||
Режет теми же правилами, что сервис при разборе страницы (src.html_extract._split_entries), чтобы
|
||||
обучение и инференс видели одинаковые фрагменты. Одну дорожку («Многоголосый, профессиональный -
|
||||
LostFilm») не трогает. nnm tech-строки уже по одной на дорожку (role_raw) — тоже не трогает.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
if r.get("studio_source") == "tech" and (r.get("role_raw") or "").strip():
|
||||
out.append(r)
|
||||
continue
|
||||
body = RE_LINE_PREFIX.sub("", (r.get("raw") or "").strip())
|
||||
parts = [p for p in _split_entries(re.sub(r"\s+", " ", body)) if RE_TYPE_HINT.search(p)]
|
||||
if len(parts) < 2:
|
||||
out.append(r)
|
||||
continue
|
||||
base_studio = (r.get("studio") or "").strip()
|
||||
for part in parts:
|
||||
e = rt_parse_entry(part)
|
||||
if e["type"] in ("оригинал", "субтитры"):
|
||||
continue
|
||||
new = dict(r)
|
||||
keep = base_studio not in ("", "(не указана)") and base_studio.casefold() in part.casefold()
|
||||
new.update(raw=part, translation_type=e["type"], professional=e["pro"],
|
||||
role_raw=e["role"], studio=base_studio if keep else (e["studio"] or "(не указана)"),
|
||||
age_18=int(has_18(part) or (keep and int(r.get("age_18") or 0))))
|
||||
new["note"] = mark_18(e["note"] or "", bool(new["age_18"]))
|
||||
out.append(new)
|
||||
return out
|
||||
|
||||
|
||||
def audio_only_rows(rec: dict, text: str, rows: list[dict]) -> list[dict]:
|
||||
"""Строки «Аудио … |MVO, Студия|» с языком, которых нет среди строк разметки (украинский дубляж и т.п).
|
||||
|
||||
Берутся только дорожки с блоком |…| (тип, студия) и определимым типом; оригинал и комментарии —
|
||||
не обучающие строки. Имена из скобок («(L,C,R,l,r)» — раскладка каналов) сюда не попадают.
|
||||
"""
|
||||
def key(x: str) -> str:
|
||||
return re.sub(r"[\W_]+", "", (x or "").casefold())
|
||||
|
||||
have = key(" ".join(((r.get("studio") or "") + " " + (r.get("raw") or "")) for r in rows))
|
||||
out = []
|
||||
for a in audio_lines_all(text):
|
||||
if a["original"] or a["commentary"]:
|
||||
continue
|
||||
m = re.search(r"\|([^|]+)\|", a["raw"])
|
||||
if not m:
|
||||
continue
|
||||
chunk = m.group(1)
|
||||
tokens = [t.strip() for t in re.split(r"\s*[,;/]\s*", chunk) if t.strip()]
|
||||
kind = pro = ""
|
||||
for t in tokens:
|
||||
if t.upper().replace(".", "") in NNM_TAG_NAMES: # короткий тег: DUB, MVO, ПМ …
|
||||
kind, pro = nnm_tag_type(t)
|
||||
break
|
||||
k, pr = translation_type(t)
|
||||
if not k.endswith("неизвестно"):
|
||||
kind, pro = k, pr
|
||||
break
|
||||
if not kind:
|
||||
continue
|
||||
studio = ""
|
||||
for t in tokens:
|
||||
if t.upper().replace(".", "") in NNM_TAG_NAMES or re.search(r"(?i)голос|дубл|оригинал|original", t):
|
||||
continue
|
||||
st, note = clean_studio(t)
|
||||
st, extra = canonical_studio(st)
|
||||
if len(st) >= 3:
|
||||
studio = st
|
||||
break
|
||||
if not studio or key(studio) in have or re.match(r"(?i)(?:blu-?ray|bd|dvd|web)\b", studio): # источник, а не студия
|
||||
continue
|
||||
base = rows[0] if rows else {}
|
||||
out.append({
|
||||
"source": base.get("source", ""), "release_id": rec.get("id", ""),
|
||||
"ru_title": base.get("ru_title", ""), "year": base.get("year", ""),
|
||||
"quality": base.get("quality", ""), "is_series": base.get("is_series", False),
|
||||
"studio": studio, "translation_type": kind, "professional": pro,
|
||||
"role_raw": chunk.strip(), "actors": "",
|
||||
"note": mark_18(" ".join(x for x in (note, extra) if x).strip(), a["adult"]),
|
||||
"age_18": int(a["adult"]), "codec": a["codec"], "channels": a["channels"], "khz": "",
|
||||
"kbps": a["kbps"], "subtitles": "", "studio_source": "audio",
|
||||
"raw": a["raw"], "lang": a["lang"], "audio_raw": a["raw"]})
|
||||
have += key(studio)
|
||||
return out
|
||||
|
||||
|
||||
def _with_audio(core, texts):
|
||||
"""Обёртка парсера: lang/audio_raw у строк; число переводов в rel — по итоговым строкам."""
|
||||
def parse(rec: dict) -> tuple[list[dict], dict]:
|
||||
rows, rel = core(rec)
|
||||
text = texts(rec)
|
||||
if rows:
|
||||
rows = enrich_rows(rows, text)
|
||||
rows = enrich_rows(split_multi_rows(rows), text)
|
||||
rows += audio_only_rows(rec, text, rows)
|
||||
elif core is _kz_parse_core:
|
||||
rows = kz_audio_only_rows(rec, text)
|
||||
if rows:
|
||||
|
||||
Reference in new issue
Block a user