lsdf commited on
Commit
02e950c
·
1 Parent(s): c204306

optimizer: per-goal deficit-based iteration and candidate budget

Browse files
Files changed (2) hide show
  1. docs/FULL_FUNCTIONAL_DOCUMENTATION.md +3 -3
  2. optimizer.py +135 -19
docs/FULL_FUNCTIONAL_DOCUMENTATION.md CHANGED
@@ -472,7 +472,7 @@ HTML extraction pipeline:
472
  - `_is_stage_complete` для `bert`:
473
  - этап считается завершённым только когда **каждая** отслеживаемая ключевая фраза достигает `bert_stage_target` (проверка по `min(bert_phrase_scores)`);
474
  - достижение порога одной «сильной» фразой больше не завершает BERT-этап.
475
- - унифицированный цикл по целям: на каждой стадии для **каждой** найденной цели/фразы действует одинаковый бюджет `max_iterations` попыток; после исчерпания лимита оптимизатор переходит к следующей цели той же стадии.
476
  - `_validate_candidate_text`:
477
  - отклоняет некачественные/спамные кандидаты (дубли слов/сущностей, подозрительные склейки токенов);
478
  - добавляет anti-stuffing фильтр для цели BERT (повторы exact phrase и чрезмерные повторы focus-термов).
@@ -480,7 +480,7 @@ HTML extraction pipeline:
480
  ### Главная функция `optimize_text`
481
  Итерационный цикл:
482
  1. baseline metrics.
483
- - общий бюджет шагов оценивается как `sum(целитадии × max_iterations)` по всем стадиям (с верхней отсечкой в коде), то есть масштабируется по числу реально требующих улучшения целей.
484
  2. выбрать goal.
485
  3. выбрать пул чанков и операцию каскада.
486
  - **Этап `title`:** если средняя BERT-близость Title к ключам (`title_bert_score`) ниже порога (`TITLE_TARGET_THRESHOLD` ≈ 0.65), цель — **только переписать текст из поля Title** (`target_title`), а не абзац основного текста. LLM получает текущий title, выдержку из body и ключевые слова; метрики пересчитываются с новым title. Пакетные правки по body с title не смешиваются.
@@ -490,7 +490,7 @@ HTML extraction pipeline:
490
  - для **n-gram** целей предложения ранжируются через **скользящие перекрывающиеся окна** из 2–4 предложений (шаг 1): каждому предложению присваивается лучший балл среди окон, оценка штрафует локальные повторы фразы и шумовые блоки;
491
  - для BERT-целей ранжирование не ограничивается участками с already-present вхождениями: дополнительно приоритизируются релевантные участки с недопредставленными core-термами, где их можно добавить естественно;
492
  - используется `attempt_cursor` по цели и `attempted_spans`, чтобы избежать циклов по одному и тому же участку.
493
- 4. сгенерировать `N` кандидатов для каждого выбранного span.
494
  5. pre-validation (формат/качество/длины).
495
  6. chunk-level оценка:
496
  - вычисляется `chunk_goal_delta` (релевантность чанка до/после к текущей цели);
 
472
  - `_is_stage_complete` для `bert`:
473
  - этап считается завершённым только когда **каждая** отслеживаемая ключевая фраза достигает `bert_stage_target` (проверка по `min(bert_phrase_scores)`);
474
  - достижение порога одной «сильной» фразой больше не завершает BERT-этап.
475
+ - унифицированный цикл по целям: базовые параметры запроса `max_iterations` и `candidates_per_iteration` задают «якорь», но для **каждой** цели вычисляется эффективный бюджет (`_per_goal_budget`): число попыток и ширина пула кандидатов **масштабируются по дефициту** до таргета — для BERT по разрыву score до порога, для semantic по `semantic_gap`, для n-gram по отставанию/перегрузу относительно целевого счётчика, для BM25 по «лишним» вхождениям слова, для title по разрыву `title_bert_score`. После исчерпания лимита по текущей цели оптимизатор переходит к следующей цели той же стадии.
476
  - `_validate_candidate_text`:
477
  - отклоняет некачественные/спамные кандидаты (дубли слов/сущностей, подозрительные склейки токенов);
478
  - добавляет anti-stuffing фильтр для цели BERT (повторы exact phrase и чрезмерные повторы focus-термов).
 
480
  ### Главная функция `optimize_text`
481
  Итерационный цикл:
482
  1. baseline metrics.
483
+ - общий бюджет шагов оценивается как **сумма эффективных итераций по всем целям** (`_estimate_total_loop_budget`: для каждой цели — `_per_goal_budget`, затем сумма по стадиям с верхней отсечкой), то есть масштабируется и по числу целей, и по величине отставания от таргета. В SSE-событии `step_start` дополнительно передаются `goal_budget_iter` и `goal_budget_candidates` для текущей цели.
484
  2. выбрать goal.
485
  3. выбрать пул чанков и операцию каскада.
486
  - **Этап `title`:** если средняя BERT-близость Title к ключам (`title_bert_score`) ниже порога (`TITLE_TARGET_THRESHOLD` ≈ 0.65), цель — **только переписать текст из поля Title** (`target_title`), а не абзац основного текста. LLM получает текущий title, выдержку из body и ключевые слова; метрики пересчитываются с новым title. Пакетные правки по body с title не смешиваются.
 
490
  - для **n-gram** целей предложения ранжируются через **скользящие перекрывающиеся окна** из 2–4 предложений (шаг 1): каждому предложению присваивается лучший балл среди окон, оценка штрафует локальные повторы фразы и шумовые блоки;
491
  - для BERT-целей ранжирование не ограничивается участками с already-present вхождениями: дополнительно приоритизируются релевантные участки с недопредставленными core-термами, где их можно добавить естественно;
492
  - используется `attempt_cursor` по цели и `attempted_spans`, чтобы избежать циклов по одному и тому же участку.
493
+ 4. сгенерировать `N` кан��идатов для каждого выбранного span (`N` зависит от эффективного бюджета кандидатов для цели и каскада, см. `_per_goal_budget` и деление по span).
494
  5. pre-validation (формат/качество/длины).
495
  6. chunk-level оценка:
496
  - вычисляется `chunk_goal_delta` (релевантность чанка до/после к текущей цели);
optimizer.py CHANGED
@@ -578,7 +578,16 @@ def _collect_optimization_goals(
578
  if not phrase:
579
  continue
580
  focus_terms = _filter_stopwords(_tokenize(phrase), language)[:4]
581
- goals.append({"type": "bert", "label": phrase, "focus_terms": focus_terms, "avoid_terms": []})
 
 
 
 
 
 
 
 
 
582
 
583
  bm25_remove = [x for x in (analysis.get("bm25_recommendations") or []) if x.get("action") == "remove"]
584
  if len(bm25_remove) >= 4:
@@ -586,7 +595,16 @@ def _collect_optimization_goals(
586
  word = str(row.get("word", "")).strip()
587
  if not word:
588
  continue
589
- goals.append({"type": "bm25", "label": f"reduce spam: {word}", "focus_terms": [], "avoid_terms": [word]})
 
 
 
 
 
 
 
 
 
590
 
591
  # Semantic keyword gaps
592
  lang_stop = STOP_WORDS.get(language, STOP_WORDS["en"])
@@ -609,8 +627,16 @@ def _collect_optimization_goals(
609
  if _is_semantic_gap(target_w, comp_w):
610
  candidate_rows.append((term, gap))
611
  if candidate_rows:
612
- for term, _gap in sorted(candidate_rows, key=lambda x: x[1], reverse=True)[:12]:
613
- goals.append({"type": "semantic", "label": term, "focus_terms": [term], "avoid_terms": []})
 
 
 
 
 
 
 
 
614
 
615
  # N-gram balancing (toward competitor average with tolerance policy).
616
  ngram_rows = _build_ngram_stage_rows(analysis, keywords, language)
@@ -638,16 +664,93 @@ def _collect_optimization_goals(
638
  and title_target_score is not None
639
  and float(title_target_score) < TITLE_TARGET_THRESHOLD
640
  ):
641
- goals.append({
642
- "type": "title",
643
- "label": "title alignment",
644
- "focus_terms": _filter_stopwords(_tokenize(" ".join(keywords[:8])), language)[:8],
645
- "avoid_terms": [],
646
- })
 
 
 
 
647
 
648
  return [g for g in goals if g.get("type") == stage]
649
 
650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  def _choose_sentence_idx(sentences: List[str], focus_terms: List[str], avoid_terms: List[str], language: str) -> int:
652
  if not sentences:
653
  return 0
@@ -1507,8 +1610,7 @@ def optimize_text(
1507
  baseline_analysis, baseline_semantic, keywords, language, bert_stage_target=bert_stage_target
1508
  )
1509
 
1510
- # Unified per-goal budget for all stages:
1511
- # total steps = sum(goals_in_stage * max_iterations)
1512
  baseline_goal_counts = {
1513
  st: len(
1514
  _collect_optimization_goals(
@@ -1523,8 +1625,15 @@ def optimize_text(
1523
  for st in STAGE_ORDER
1524
  }
1525
  ngram_row_count = int(baseline_goal_counts.get("ngram", 0))
1526
- estimated_total = sum(int(c) * int(max_iterations) for c in baseline_goal_counts.values())
1527
- total_loop_steps = min(240, max(1, estimated_total))
 
 
 
 
 
 
 
1528
 
1529
  current_text = target_text
1530
  current_title = (target_title or "").strip()
@@ -1621,8 +1730,12 @@ def optimize_text(
1621
  goal_index = int(state.get("goal_index", 0))
1622
  attempt_count = int(state.get("attempt_count", 0))
1623
 
1624
- # Advance across goals that exhausted per-goal iteration budget.
1625
- while goal_index < len(goals_for_stage) and attempt_count >= max_iterations:
 
 
 
 
1626
  goal_index += 1
1627
  attempt_count = 0
1628
 
@@ -1634,13 +1747,14 @@ def optimize_text(
1634
  "step": step + 1,
1635
  "status": "stage_skipped",
1636
  "stage": active_stage,
1637
- "reason": f"All goals exhausted for stage '{active_stage}' (max_iterations={max_iterations} per goal).",
1638
  }
1639
  )
1640
  stage_goal_cursor[active_stage] = {"goal_index": goal_index, "attempt_count": attempt_count}
1641
  continue
1642
 
1643
  goal = goals_for_stage[goal_index]
 
1644
  attempt_count += 1
1645
  stage_goal_cursor[active_stage] = {"goal_index": goal_index, "attempt_count": attempt_count}
1646
  if goal["type"] == "none":
@@ -1664,6 +1778,8 @@ def optimize_text(
1664
  goal_type=goal.get("type"),
1665
  goal_label=goal.get("label"),
1666
  score=current_metrics.get("score"),
 
 
1667
  )
1668
 
1669
  goal_key = f"{goal.get('type', '')}:{goal.get('label', '')}".strip().lower()
@@ -1706,7 +1822,7 @@ def optimize_text(
1706
  phrase_strategy_mode,
1707
  "title",
1708
  str(goal.get("label", "")),
1709
- candidates_per_iteration,
1710
  )
1711
  for strategy_variant in strategy_plan:
1712
  candidate_idx += 1
@@ -1913,7 +2029,7 @@ def optimize_text(
1913
  break
1914
 
1915
  span_trials = 2 if cascade_level <= 2 else 3
1916
- local_candidates = candidates_per_iteration if cascade_level <= 2 else min(6, candidates_per_iteration + 1)
1917
  span_trials_eff = span_trials
1918
 
1919
  for st in range(span_trials):
 
578
  if not phrase:
579
  continue
580
  focus_terms = _filter_stopwords(_tokenize(phrase), language)[:4]
581
+ goals.append(
582
+ {
583
+ "type": "bert",
584
+ "label": phrase,
585
+ "focus_terms": focus_terms,
586
+ "avoid_terms": [],
587
+ "bert_phrase_score": float(row.get("my_max_score", 0) or 0.0),
588
+ "bert_target": float(bert_stage_target),
589
+ }
590
+ )
591
 
592
  bm25_remove = [x for x in (analysis.get("bm25_recommendations") or []) if x.get("action") == "remove"]
593
  if len(bm25_remove) >= 4:
 
595
  word = str(row.get("word", "")).strip()
596
  if not word:
597
  continue
598
+ goals.append(
599
+ {
600
+ "type": "bm25",
601
+ "label": f"reduce spam: {word}",
602
+ "focus_terms": [],
603
+ "avoid_terms": [word],
604
+ "bm25_count": int(row.get("count", 0) or 0),
605
+ "bm25_word": word,
606
+ }
607
+ )
608
 
609
  # Semantic keyword gaps
610
  lang_stop = STOP_WORDS.get(language, STOP_WORDS["en"])
 
627
  if _is_semantic_gap(target_w, comp_w):
628
  candidate_rows.append((term, gap))
629
  if candidate_rows:
630
+ for term, gap in sorted(candidate_rows, key=lambda x: x[1], reverse=True)[:12]:
631
+ goals.append(
632
+ {
633
+ "type": "semantic",
634
+ "label": term,
635
+ "focus_terms": [term],
636
+ "avoid_terms": [],
637
+ "semantic_gap": float(gap),
638
+ }
639
+ )
640
 
641
  # N-gram balancing (toward competitor average with tolerance policy).
642
  ngram_rows = _build_ngram_stage_rows(analysis, keywords, language)
 
664
  and title_target_score is not None
665
  and float(title_target_score) < TITLE_TARGET_THRESHOLD
666
  ):
667
+ goals.append(
668
+ {
669
+ "type": "title",
670
+ "label": "title alignment",
671
+ "focus_terms": _filter_stopwords(_tokenize(" ".join(keywords[:8])), language)[:8],
672
+ "avoid_terms": [],
673
+ "title_bert_score": float(title_target_score) if title_target_score is not None else None,
674
+ "title_target": float(TITLE_TARGET_THRESHOLD),
675
+ }
676
+ )
677
 
678
  return [g for g in goals if g.get("type") == stage]
679
 
680
 
681
+ def _per_goal_budget(
682
+ goal: Dict[str, Any],
683
+ max_iterations: int,
684
+ candidates_per_iteration: int,
685
+ bert_stage_target: float,
686
+ ) -> Tuple[int, int]:
687
+ """
688
+ Scale per-goal iteration and candidate budgets by how far the metric is from its target.
689
+ Returns (effective_max_iterations_for_this_goal, effective_candidates_per_iteration).
690
+ """
691
+ t = str(goal.get("type", "") or "")
692
+ raw = 0.0
693
+
694
+ if t == "bert":
695
+ sc = float(goal.get("bert_phrase_score", 0.0) or 0.0)
696
+ tgt = float(goal.get("bert_target", bert_stage_target) or bert_stage_target)
697
+ raw = max(0.0, (tgt - sc) / max(tgt, 1e-6))
698
+ elif t == "ngram":
699
+ ca = float(goal.get("ngram_comp_avg", 0.0) or 0.0)
700
+ tc = float(goal.get("ngram_target_count", 0.0) or 0.0)
701
+ if str(goal.get("ngram_direction", "increase")) == "increase":
702
+ need = max(0.0, ca - tc)
703
+ raw = min(1.0, need / max(ca, 1e-6))
704
+ else:
705
+ need = max(0.0, tc - ca)
706
+ raw = min(1.0, need / max(tc, 1e-6))
707
+ elif t == "semantic":
708
+ gap = float(goal.get("semantic_gap", 0.0) or 0.0)
709
+ raw = min(1.0, gap / max(SEMANTIC_GAP_MIN_ABS * 4.0, 1e-6))
710
+ elif t == "bm25":
711
+ c = int(goal.get("bm25_count", 0) or 0)
712
+ raw = min(1.0, max(0, c - 1) / 8.0)
713
+ elif t == "title":
714
+ ts = goal.get("title_bert_score")
715
+ if ts is None:
716
+ raw = 0.5
717
+ else:
718
+ tgt = float(goal.get("title_target", TITLE_TARGET_THRESHOLD) or TITLE_TARGET_THRESHOLD)
719
+ raw = max(0.0, (tgt - float(ts)) / max(tgt, 1e-6))
720
+ else:
721
+ raw = 0.0
722
+
723
+ iter_mult = 1.0 + 2.0 * min(1.0, raw)
724
+ cand_mult = 1.0 + 1.0 * min(1.0, raw)
725
+ eff_iter = max(1, min(int(round(max_iterations * iter_mult)), max_iterations * 3))
726
+ eff_cand = max(1, min(int(round(candidates_per_iteration * cand_mult)), 5))
727
+ return eff_iter, eff_cand
728
+
729
+
730
+ def _estimate_total_loop_budget(
731
+ analysis: Dict[str, Any],
732
+ semantic: Dict[str, Any],
733
+ keywords: List[str],
734
+ language: str,
735
+ max_iterations: int,
736
+ candidates_per_iteration: int,
737
+ bert_stage_target: float,
738
+ ) -> int:
739
+ total = 0
740
+ for st in STAGE_ORDER:
741
+ for g in _collect_optimization_goals(
742
+ analysis,
743
+ semantic,
744
+ keywords,
745
+ language,
746
+ stage=st,
747
+ bert_stage_target=bert_stage_target,
748
+ ):
749
+ ei, _ = _per_goal_budget(g, max_iterations, candidates_per_iteration, bert_stage_target)
750
+ total += ei
751
+ return min(480, max(1, total))
752
+
753
+
754
  def _choose_sentence_idx(sentences: List[str], focus_terms: List[str], avoid_terms: List[str], language: str) -> int:
755
  if not sentences:
756
  return 0
 
1610
  baseline_analysis, baseline_semantic, keywords, language, bert_stage_target=bert_stage_target
1611
  )
1612
 
1613
+ # Per-goal iteration budget scales with deficit; total loop steps = sum(effective iters per goal).
 
1614
  baseline_goal_counts = {
1615
  st: len(
1616
  _collect_optimization_goals(
 
1625
  for st in STAGE_ORDER
1626
  }
1627
  ngram_row_count = int(baseline_goal_counts.get("ngram", 0))
1628
+ total_loop_steps = _estimate_total_loop_budget(
1629
+ baseline_analysis,
1630
+ baseline_semantic,
1631
+ keywords,
1632
+ language,
1633
+ max_iterations,
1634
+ candidates_per_iteration,
1635
+ bert_stage_target,
1636
+ )
1637
 
1638
  current_text = target_text
1639
  current_title = (target_title or "").strip()
 
1730
  goal_index = int(state.get("goal_index", 0))
1731
  attempt_count = int(state.get("attempt_count", 0))
1732
 
1733
+ # Advance across goals that exhausted per-goal iteration budget (scaled by deficit).
1734
+ while goal_index < len(goals_for_stage):
1735
+ g_try = goals_for_stage[goal_index]
1736
+ eff_max_iter, _ = _per_goal_budget(g_try, max_iterations, candidates_per_iteration, bert_stage_target)
1737
+ if attempt_count < eff_max_iter:
1738
+ break
1739
  goal_index += 1
1740
  attempt_count = 0
1741
 
 
1747
  "step": step + 1,
1748
  "status": "stage_skipped",
1749
  "stage": active_stage,
1750
+ "reason": f"All goals exhausted for stage '{active_stage}' (per-goal iteration budget).",
1751
  }
1752
  )
1753
  stage_goal_cursor[active_stage] = {"goal_index": goal_index, "attempt_count": attempt_count}
1754
  continue
1755
 
1756
  goal = goals_for_stage[goal_index]
1757
+ eff_max_iter, eff_cand = _per_goal_budget(goal, max_iterations, candidates_per_iteration, bert_stage_target)
1758
  attempt_count += 1
1759
  stage_goal_cursor[active_stage] = {"goal_index": goal_index, "attempt_count": attempt_count}
1760
  if goal["type"] == "none":
 
1778
  goal_type=goal.get("type"),
1779
  goal_label=goal.get("label"),
1780
  score=current_metrics.get("score"),
1781
+ goal_budget_iter=eff_max_iter,
1782
+ goal_budget_candidates=eff_cand,
1783
  )
1784
 
1785
  goal_key = f"{goal.get('type', '')}:{goal.get('label', '')}".strip().lower()
 
1822
  phrase_strategy_mode,
1823
  "title",
1824
  str(goal.get("label", "")),
1825
+ eff_cand,
1826
  )
1827
  for strategy_variant in strategy_plan:
1828
  candidate_idx += 1
 
2029
  break
2030
 
2031
  span_trials = 2 if cascade_level <= 2 else 3
2032
+ local_candidates = eff_cand if cascade_level <= 2 else min(6, eff_cand + 1)
2033
  span_trials_eff = span_trials
2034
 
2035
  for st in range(span_trials):