包含 Persona 锚定聚类、LLM 报告生成、方法论文档及 .gitignore 更新,便于在 Gitee 独立部署。 Co-authored-by: Cursor <cursoragent@cursor.com>
1273 lines
43 KiB
Python
1273 lines
43 KiB
Python
"""
|
||
从 voc_embeddings.sqlite 读取向量,按五段流程 UMAP+HDBSCAN 聚类,结果写入 voc_clustering.sqlite。
|
||
|
||
流程:
|
||
1. audience(LLM 自动调参 n_neighbors)
|
||
2a. 前两 audience 簇各自独立:簇内 pain_point(LLM 自动调参)
|
||
2b. 前两 audience 簇各自独立:簇内 aspect_opinion 按 Positive/Negative/Neutral 分桶聚类
|
||
3a. 全量 pain_point(LLM 自动调参)
|
||
3b. 全量 aspect_opinion 按 Positive/Negative/Neutral 分桶聚类(各自 LLM 自动调参)
|
||
|
||
规则:
|
||
- 聚类单元:每行 embedding_items
|
||
- n < 10:不跑 HDBSCAN,逐条独立簇标签(0..n-1)
|
||
- 步骤 1/2 的 -1:写入 SQL,不参与后续(步骤 1 的 -1 不进入步骤 2)
|
||
- 步骤 3 的 -1:写入 SQL,参与后续统计
|
||
|
||
用法::
|
||
|
||
./310py/bin/python 聚类.py
|
||
# 默认自动使用 voc_structured.sqlite 中最新 analysis_jobs.id(须已向量化)
|
||
./310py/bin/python 聚类.py --job-id 4 # 可选:手动指定
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import json
|
||
import logging
|
||
import os
|
||
import random
|
||
import re
|
||
import sqlite3
|
||
import struct
|
||
import sys
|
||
import time
|
||
import warnings
|
||
from collections import defaultdict
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||
|
||
import hdbscan
|
||
import numpy as np
|
||
import umap
|
||
from openai import OpenAI
|
||
from sklearn.metrics import silhouette_score
|
||
|
||
from voc_llm import CHAT_MODEL, chat_extra_body, create_chat_client, require_chat_api_key
|
||
|
||
# UMAP 设 random_state 时的单线程提示;轮廓系数在 extmath 中的 matmul 数值告警
|
||
warnings.filterwarnings(
|
||
"ignore",
|
||
message=r"n_jobs value .* overridden .* by setting random_state",
|
||
category=UserWarning,
|
||
module=r"umap\.umap_",
|
||
)
|
||
warnings.filterwarnings(
|
||
"ignore",
|
||
category=RuntimeWarning,
|
||
module=r"sklearn\.utils\.extmath",
|
||
)
|
||
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
stream=sys.stderr,
|
||
)
|
||
logger = logging.getLogger("voc_cluster")
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||
STRUCTURED_DB = PROJECT_ROOT / "voc_structured.sqlite"
|
||
EMBED_DB = PROJECT_ROOT / "voc_embeddings.sqlite"
|
||
CLUSTER_DB = PROJECT_ROOT / "voc_clustering.sqlite"
|
||
LLM_MODEL = CHAT_MODEL
|
||
|
||
UMAP_N_COMPONENTS = 30
|
||
UMAP_MIN_DIST = 0.1
|
||
UMAP_METRIC = "cosine"
|
||
UMAP_RANDOM_STATE = 42
|
||
MIN_POINTS_FOR_HDBSCAN = 10
|
||
# HDBSCAN 目标:非离群簇数不超过该值(通过提高 min_cluster_size 达成)
|
||
MAX_CLUSTERS = 20
|
||
|
||
VALID_SENTIMENTS: Tuple[str, ...] = ("Positive", "Negative", "Neutral")
|
||
_SENTIMENT_STAGE_SUFFIX: Dict[str, str] = {
|
||
"Positive": "positive",
|
||
"Negative": "negative",
|
||
"Neutral": "neutral",
|
||
}
|
||
|
||
HDBSCAN_BASE = dict(
|
||
min_samples=1,
|
||
cluster_selection_method="eom",
|
||
prediction_data=True,
|
||
)
|
||
|
||
INITIAL_N_NEIGHBORS = 10
|
||
MAX_N_NEIGHBORS = 45
|
||
CROSS_SIMILAR_RATIO_THRESHOLD = 0.10
|
||
SILHOUETTE_STOP_THRESHOLD = 0.6
|
||
SILHOUETTE_DECLINE_WINDOW = 10 # 连续 N 个轮廓值:后 N-1 个均小于第 1 个则停止
|
||
SILHOUETTE_NEIGHBOR_MARGIN = 0.03 # 邻轮轮廓 ≥ 峰值−此值视为「接近」,参与离群数决胜
|
||
SILHOUETTE_NEIGHBOR_RADIUS = 3 # 峰值轮次前后各 3 轮
|
||
SAMPLE_CAP = 30
|
||
SAMPLE_RATIO = 0.6
|
||
|
||
# 步骤 2 未启用 LLM 调参时的保守默认(当前步骤 2 已启用自动调参)
|
||
STEP2_N_NEIGHBORS = 8
|
||
|
||
# 簇内去重评论数 / 本步骤参与聚类的去重评论总数 < 该比例则不写入库、不进入报告
|
||
CLUSTER_MIN_REVIEW_RATIO = 0.10
|
||
|
||
|
||
@dataclass
|
||
class EmbedRow:
|
||
id: int
|
||
job_id: int
|
||
extraction_id: int
|
||
source_row: int
|
||
entity_type: str
|
||
entity_index: int
|
||
embed_text: str
|
||
audience: str | None
|
||
aspect: str | None
|
||
opinion: str | None
|
||
category: str | None
|
||
sentiment: str | None
|
||
content: str
|
||
dimensions: int
|
||
embedding: np.ndarray
|
||
|
||
|
||
def _unpack_embedding(blob: bytes, dimensions: int) -> np.ndarray:
|
||
n = dimensions
|
||
expected = n * 4
|
||
if len(blob) != expected:
|
||
raise ValueError(f"BLOB 长度 {len(blob)} != {expected}(dim={n})")
|
||
return np.array(struct.unpack(f"{n}f", blob), dtype=np.float32)
|
||
|
||
|
||
def _latest_job_id_from_structured(structured_db: Path) -> int | None:
|
||
"""与 向量化.py 一致:analysis_jobs 表 id 最大者为最新 job。"""
|
||
if not structured_db.is_file():
|
||
return None
|
||
conn = sqlite3.connect(structured_db)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT id FROM analysis_jobs ORDER BY id DESC LIMIT 1"
|
||
).fetchone()
|
||
return int(row[0]) if row else None
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def _latest_job_id_from_embeddings(conn: sqlite3.Connection) -> int | None:
|
||
row = conn.execute(
|
||
"SELECT job_id FROM embedding_items ORDER BY job_id DESC LIMIT 1"
|
||
).fetchone()
|
||
return int(row[0]) if row else None
|
||
|
||
|
||
def _count_embeddings(conn: sqlite3.Connection, job_id: int) -> int:
|
||
row = conn.execute(
|
||
"SELECT COUNT(*) FROM embedding_items WHERE job_id = ?", (job_id,)
|
||
).fetchone()
|
||
return int(row[0]) if row else 0
|
||
|
||
|
||
def _resolve_job_id(
|
||
job_id: int | None,
|
||
embed_conn: sqlite3.Connection,
|
||
structured_db: Path = STRUCTURED_DB,
|
||
) -> int:
|
||
"""未传 job_id 时自动选最新结构化 job,并确认向量库中已有数据。"""
|
||
if job_id is not None:
|
||
jid = job_id
|
||
if _count_embeddings(embed_conn, jid) == 0:
|
||
raise RuntimeError(
|
||
f"job_id={jid} 在 {EMBED_DB.name} 中无向量,请先运行 向量化.py"
|
||
)
|
||
logger.info("使用指定 job_id=%s(%s 条向量)", jid, _count_embeddings(embed_conn, jid))
|
||
return jid
|
||
|
||
jid = _latest_job_id_from_structured(structured_db)
|
||
source = "voc_structured.analysis_jobs"
|
||
if jid is None:
|
||
jid = _latest_job_id_from_embeddings(embed_conn)
|
||
source = "voc_embeddings.embedding_items"
|
||
if jid is None:
|
||
raise RuntimeError("无可用 job_id:请先运行 结构化_server.py 与 向量化.py")
|
||
|
||
n = _count_embeddings(embed_conn, jid)
|
||
if n == 0:
|
||
latest_embed = _latest_job_id_from_embeddings(embed_conn)
|
||
if latest_embed is not None and latest_embed != jid:
|
||
logger.warning(
|
||
"结构化最新 job_id=%s 尚无向量,改用向量库最新 job_id=%s",
|
||
jid,
|
||
latest_embed,
|
||
)
|
||
jid = latest_embed
|
||
n = _count_embeddings(embed_conn, jid)
|
||
source = "voc_embeddings.embedding_items(回退)"
|
||
else:
|
||
raise RuntimeError(
|
||
f"最新 job_id={jid} 在向量库中无数据,请先对应该 job 运行 向量化.py"
|
||
)
|
||
|
||
logger.info("自动选用最新 job_id=%s(来源: %s,%s 条向量)", jid, source, n)
|
||
return jid
|
||
|
||
|
||
def _load_embed_rows(
|
||
conn: sqlite3.Connection, job_id: int, entity_type: str
|
||
) -> List[EmbedRow]:
|
||
cur = conn.execute(
|
||
"""
|
||
SELECT id, job_id, extraction_id, source_row, entity_type, entity_index,
|
||
embed_text, audience, aspect, opinion, category, sentiment,
|
||
content, dimensions, embedding
|
||
FROM embedding_items
|
||
WHERE job_id = ? AND entity_type = ?
|
||
ORDER BY source_row, entity_index, id
|
||
""",
|
||
(job_id, entity_type),
|
||
)
|
||
out: List[EmbedRow] = []
|
||
for r in cur.fetchall():
|
||
dims = int(r[13])
|
||
out.append(
|
||
EmbedRow(
|
||
id=int(r[0]),
|
||
job_id=int(r[1]),
|
||
extraction_id=int(r[2]),
|
||
source_row=int(r[3]),
|
||
entity_type=str(r[4]),
|
||
entity_index=int(r[5]),
|
||
embed_text=str(r[6]),
|
||
audience=r[7],
|
||
aspect=r[8],
|
||
opinion=r[9],
|
||
category=r[10],
|
||
sentiment=r[11],
|
||
content=str(r[12] or ""),
|
||
dimensions=dims,
|
||
embedding=_unpack_embedding(r[14], dims),
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
def _filter_by_source_rows(
|
||
rows: List[EmbedRow], allowed: set[int]
|
||
) -> List[EmbedRow]:
|
||
return [r for r in rows if r.source_row in allowed]
|
||
|
||
|
||
def _strict_sentiment(raw: str | None) -> str | None:
|
||
"""仅接受 Positive / Negative / Neutral,其余丢弃。"""
|
||
if raw is None:
|
||
return None
|
||
s = str(raw).strip()
|
||
if s in VALID_SENTIMENTS:
|
||
return s
|
||
return None
|
||
|
||
|
||
def _filter_by_sentiment(rows: List[EmbedRow], sentiment: str) -> List[EmbedRow]:
|
||
return [r for r in rows if _strict_sentiment(r.sentiment) == sentiment]
|
||
|
||
|
||
def _stage_3b(sentiment: str) -> str:
|
||
return f"3b_aspect_opinion_{_SENTIMENT_STAGE_SUFFIX[sentiment]}"
|
||
|
||
|
||
def _stage_2b(sentiment: str, audience_cluster: int) -> str:
|
||
return f"2b_aspect_opinion_{_SENTIMENT_STAGE_SUFFIX[sentiment]}_audience_c{audience_cluster}"
|
||
|
||
|
||
def _cluster_aspect_opinion_sentiment_stages(
|
||
cconn: sqlite3.Connection,
|
||
run_id: int,
|
||
ao_rows: List[EmbedRow],
|
||
*,
|
||
stage_for: Any,
|
||
outlier_participates: bool,
|
||
parent_audience_cluster: int | None,
|
||
skipped_reason_prefix: str,
|
||
client: OpenAI,
|
||
) -> Dict[str, int]:
|
||
"""对 aspect_opinion 按三档情感各跑一 stage,返回 stage -> 条数。"""
|
||
counts: Dict[str, int] = {}
|
||
for sentiment in VALID_SENTIMENTS:
|
||
stage = stage_for(sentiment)
|
||
sub = _filter_by_sentiment(ao_rows, sentiment)
|
||
logger.info(
|
||
"[%s] %s 条 aspect_opinion(情感=%s)",
|
||
stage,
|
||
len(sub),
|
||
sentiment,
|
||
)
|
||
if not sub:
|
||
_save_stage_meta(
|
||
cconn,
|
||
run_id,
|
||
stage,
|
||
{
|
||
"skipped": True,
|
||
"reason": f"{skipped_reason_prefix}_{_SENTIMENT_STAGE_SUFFIX[sentiment]}",
|
||
"sentiment": sentiment,
|
||
},
|
||
)
|
||
counts[stage] = 0
|
||
continue
|
||
labels, meta = _cluster_stage(
|
||
sub, stage=stage, use_llm_tune=True, client=client
|
||
)
|
||
parent_clusters = None
|
||
if parent_audience_cluster is not None:
|
||
parent_clusters = [parent_audience_cluster] * len(sub)
|
||
filt = _save_assignments(
|
||
cconn,
|
||
run_id,
|
||
stage,
|
||
sub,
|
||
labels,
|
||
n_neighbors=meta.get("n_neighbors"),
|
||
outlier_participates=outlier_participates,
|
||
parent_clusters=parent_clusters,
|
||
)
|
||
meta["cluster_filter"] = filt
|
||
meta["sentiment"] = sentiment
|
||
_save_stage_meta(cconn, run_id, stage, meta)
|
||
if meta.get("tuning_log"):
|
||
_save_tuning_logs(cconn, run_id, stage, meta["tuning_log"])
|
||
counts[stage] = len(sub)
|
||
return counts
|
||
|
||
|
||
def _source_rows_for_audience_cluster(
|
||
row_to_aud: Dict[int, int], audience_cluster: int
|
||
) -> set[int]:
|
||
return {sr for sr, lab in row_to_aud.items() if lab == audience_cluster}
|
||
|
||
|
||
def _stage_name_step2_pain(audience_cluster: int) -> str:
|
||
return f"2a_pain_audience_c{audience_cluster}"
|
||
|
||
|
||
def _cluster_step2_for_audience(
|
||
cconn: sqlite3.Connection,
|
||
run_id: int,
|
||
*,
|
||
audience_cluster: int,
|
||
pain_rows: List[EmbedRow],
|
||
ao_rows: List[EmbedRow],
|
||
row_to_aud: Dict[int, int],
|
||
client: OpenAI,
|
||
) -> Dict[str, int]:
|
||
"""对单个 audience 簇分别聚类 pain 与 aspect_opinion,返回各 stage 条数。"""
|
||
allowed = _source_rows_for_audience_cluster(row_to_aud, audience_cluster)
|
||
counts: Dict[str, int] = {}
|
||
pain_sub = _filter_by_source_rows(pain_rows, allowed)
|
||
ao_sub = _filter_by_source_rows(ao_rows, allowed)
|
||
|
||
stage_pain = _stage_name_step2_pain(audience_cluster)
|
||
logger.info(
|
||
"[%s] audience 簇 %s:%s 条 pain(%s 条评论)",
|
||
stage_pain,
|
||
audience_cluster,
|
||
len(pain_sub),
|
||
len(allowed),
|
||
)
|
||
if pain_sub:
|
||
labels_pain, meta_pain = _cluster_stage(
|
||
pain_sub, stage=stage_pain, use_llm_tune=True, client=client
|
||
)
|
||
filt = _save_assignments(
|
||
cconn,
|
||
run_id,
|
||
stage_pain,
|
||
pain_sub,
|
||
labels_pain,
|
||
n_neighbors=meta_pain.get("n_neighbors"),
|
||
outlier_participates=False,
|
||
parent_clusters=[audience_cluster] * len(pain_sub),
|
||
)
|
||
meta_pain["cluster_filter"] = filt
|
||
_save_stage_meta(cconn, run_id, stage_pain, meta_pain)
|
||
if meta_pain.get("tuning_log"):
|
||
_save_tuning_logs(cconn, run_id, stage_pain, meta_pain["tuning_log"])
|
||
counts[stage_pain] = len(pain_sub)
|
||
else:
|
||
_save_stage_meta(
|
||
cconn,
|
||
run_id,
|
||
stage_pain,
|
||
{"skipped": True, "reason": "no_pain_points", "audience_cluster": audience_cluster},
|
||
)
|
||
counts[stage_pain] = 0
|
||
|
||
ao_counts = _cluster_aspect_opinion_sentiment_stages(
|
||
cconn,
|
||
run_id,
|
||
ao_sub,
|
||
stage_for=lambda s: _stage_2b(s, audience_cluster),
|
||
outlier_participates=False,
|
||
parent_audience_cluster=audience_cluster,
|
||
skipped_reason_prefix="no_aspect_opinion",
|
||
client=client,
|
||
)
|
||
counts.update(ao_counts)
|
||
|
||
return counts
|
||
|
||
|
||
def _adaptive_min_cluster_size(n: int) -> int:
|
||
"""初始 min_cluster_size = max(2, n // MAX_CLUSTERS),无上限;簇数仍 > MAX_CLUSTERS 时再迭代增大。"""
|
||
if n < MIN_POINTS_FOR_HDBSCAN:
|
||
return 2
|
||
return max(2, n // MAX_CLUSTERS)
|
||
|
||
|
||
def _n_clusters_from_labels(labels: np.ndarray) -> int:
|
||
labs = {int(x) for x in labels.tolist()}
|
||
labs.discard(-1)
|
||
return len(labs)
|
||
|
||
|
||
def _stack_embeddings(rows: Sequence[EmbedRow]) -> np.ndarray:
|
||
return np.vstack([r.embedding for r in rows]).astype(np.float32)
|
||
|
||
|
||
def _fit_umap(embeddings: np.ndarray, n_neighbors: int) -> np.ndarray:
|
||
n = len(embeddings)
|
||
n_neighbors = min(n_neighbors, max(2, n - 1))
|
||
reducer = umap.UMAP(
|
||
n_components=min(UMAP_N_COMPONENTS, max(2, n - 2)),
|
||
n_neighbors=n_neighbors,
|
||
min_dist=UMAP_MIN_DIST,
|
||
metric=UMAP_METRIC,
|
||
random_state=UMAP_RANDOM_STATE,
|
||
)
|
||
return reducer.fit_transform(embeddings)
|
||
|
||
|
||
def _fit_hdbscan(umap_emb: np.ndarray, min_cluster_size: int) -> np.ndarray:
|
||
clusterer = hdbscan.HDBSCAN(
|
||
min_cluster_size=min_cluster_size,
|
||
**HDBSCAN_BASE,
|
||
)
|
||
return clusterer.fit_predict(umap_emb)
|
||
|
||
|
||
def _run_umap_hdbscan_capped(
|
||
embeddings: np.ndarray,
|
||
n_neighbors: int,
|
||
*,
|
||
max_clusters: int = MAX_CLUSTERS,
|
||
) -> Tuple[np.ndarray, np.ndarray, int]:
|
||
"""UMAP + HDBSCAN;若簇数 > max_clusters 则增大 min_cluster_size 直至满足或无法再增。"""
|
||
n = len(embeddings)
|
||
umap_emb = _fit_umap(embeddings, n_neighbors)
|
||
min_cs = _adaptive_min_cluster_size(n)
|
||
labels = _fit_hdbscan(umap_emb, min_cs)
|
||
n_clusters = _n_clusters_from_labels(labels)
|
||
|
||
while n_clusters > max_clusters and min_cs < n:
|
||
step = max(1, (n_clusters - max_clusters + 1) // 2)
|
||
next_cs = min(min_cs + step, n)
|
||
if next_cs <= min_cs:
|
||
break
|
||
logger.info(
|
||
"簇数 %s > %s,min_cluster_size %s -> %s",
|
||
n_clusters,
|
||
max_clusters,
|
||
min_cs,
|
||
next_cs,
|
||
)
|
||
min_cs = next_cs
|
||
labels = _fit_hdbscan(umap_emb, min_cs)
|
||
n_clusters = _n_clusters_from_labels(labels)
|
||
|
||
if n_clusters > max_clusters:
|
||
logger.warning(
|
||
"簇数 %s 仍 > %s(min_cluster_size=%s, n=%s)",
|
||
n_clusters,
|
||
max_clusters,
|
||
min_cs,
|
||
n,
|
||
)
|
||
return labels, umap_emb, min_cs
|
||
|
||
|
||
def _labels_all_zero(n: int) -> np.ndarray:
|
||
return np.zeros(n, dtype=int)
|
||
|
||
|
||
def _strip_think(text: str) -> str:
|
||
if not text:
|
||
return text
|
||
text = re.sub(
|
||
r"<think>[\s\S]*?</think>", "", text, flags=re.IGNORECASE
|
||
)
|
||
text = re.sub(r"</?think>", "", text, flags=re.IGNORECASE)
|
||
return text.strip()
|
||
|
||
|
||
def _parse_json_from_llm(text: str) -> dict:
|
||
text = _strip_think(text or "")
|
||
text = text.replace("```json", "").replace("```JSON", "").replace("```", "").strip()
|
||
start = text.find("{")
|
||
end = text.rfind("}")
|
||
if start == -1 or end <= start:
|
||
raise ValueError("未找到 JSON 对象")
|
||
return json.loads(text[start : end + 1])
|
||
|
||
|
||
def _sample_from_clusters(
|
||
sentences: List[str], cluster_labels: np.ndarray, rng: random.Random
|
||
) -> Dict[int, List[str]]:
|
||
groups: Dict[int, List[str]] = {}
|
||
for sent, lab in zip(sentences, cluster_labels.tolist()):
|
||
groups.setdefault(int(lab), []).append(sent)
|
||
samples: Dict[int, List[str]] = {}
|
||
for lab, sents in groups.items():
|
||
if lab == -1:
|
||
continue
|
||
n = len(sents)
|
||
k = min(SAMPLE_CAP, max(1, int(n * SAMPLE_RATIO)))
|
||
k = min(k, n)
|
||
samples[lab] = rng.sample(sents, k) if n > k else list(sents)
|
||
return samples
|
||
|
||
|
||
def _ai_evaluate_cluster_samples(
|
||
client: OpenAI, cluster_samples: Dict[int, List[str]], max_retries: int = 2
|
||
) -> dict:
|
||
if not cluster_samples:
|
||
return {"cross_similar_count": 0, "total_sentences": 0}
|
||
lines = []
|
||
total = 0
|
||
for lab in sorted(cluster_samples.keys()):
|
||
lines.append(f"【聚类{lab}】")
|
||
for i, sent in enumerate(cluster_samples[lab], 1):
|
||
lines.append(f" {i}. {sent}")
|
||
total += 1
|
||
sample_text = "\n".join(lines)
|
||
prompt = f"""你是 VOC 评论短语聚类质量评估助手。以下是多个聚类类别的抽样短语(英文为主)。
|
||
|
||
{sample_text}
|
||
|
||
任务:统计 cross_similar_count——**不同聚类类别之间**、语义相近的短语条数。
|
||
|
||
## 判断标准(从宽,不要漏判)
|
||
将两条短语判为「跨类相似」,只要它们表达的是**同一类用户意图/问题/反馈**,不要求措辞一致。以下情况**都应计入**:
|
||
- 同义改写:如 "doesn't work" 与 "not effective"
|
||
- 同一痛点不同说法:如 "cat pees on bed" 与 "urinates on sofa"
|
||
- 同一产品缺陷的不同表述:如 "strong smell" 与 "odor too strong"
|
||
- 核心对象相同、评价方向相同:如 "sprayer broken" 与 "nozzle stopped working"
|
||
- 一方是另一方的子集或概括:如 "joint pain" 与 "severe joint pain in elderly dog"
|
||
|
||
以下情况**不计入**:
|
||
- 仅在同一聚类类别内部相似(不算跨类)
|
||
- 明显不同方面:如 "fast shipping" 与 "bad smell"
|
||
- 褒贬相反:如 "works great" 与 "doesn't work at all"
|
||
|
||
## 计数规则
|
||
1. 逐条短语与其他聚类中的短语比对;只要与**任一**其他类的**任一**短语相近,该条计 1。
|
||
2. 每条短语最多计 1 次。
|
||
3. 宁可多计疑似相近,也不要漏掉明显同义/同主题的跨类重复。
|
||
|
||
只输出 JSON:
|
||
{{
|
||
"cross_similar_count": 整数,
|
||
"total_sentences": {total}
|
||
}}"""
|
||
for attempt in range(max_retries):
|
||
try:
|
||
resp = client.chat.completions.create(
|
||
model=LLM_MODEL,
|
||
messages=[
|
||
{"role": "system", "content": "你是聚类质量评估助手。跨类相似判断从宽:同主题、同义改写、同一痛点/反馈的不同说法都应算相似。只输出合法 JSON。"},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
max_tokens=200_000,
|
||
temperature=0.0,
|
||
response_format={"type": "json_object"},
|
||
# 关闭思考,避免 token 耗在 reasoning_content 导致 content 为空且无 JSON
|
||
extra_body=chat_extra_body(LLM_MODEL),
|
||
)
|
||
msg = resp.choices[0].message
|
||
raw = msg.content or getattr(msg, "reasoning_content", None) or ""
|
||
data = _parse_json_from_llm(raw)
|
||
data["total_sentences"] = int(data.get("total_sentences", total) or total)
|
||
data["cross_similar_count"] = int(data.get("cross_similar_count", 0))
|
||
return data
|
||
except Exception as e:
|
||
logger.warning("AI 评估失败(第%s次): %s", attempt + 1, e)
|
||
if attempt < max_retries - 1:
|
||
time.sleep(1)
|
||
return {"cross_similar_count": total, "total_sentences": total}
|
||
|
||
|
||
def _silhouette_decline_should_stop(scores: List[float]) -> bool:
|
||
"""最近 SILHOUETTE_DECLINE_WINDOW 个有效轮廓系数中,后 N-1 个是否都严格小于第一个。"""
|
||
if len(scores) < SILHOUETTE_DECLINE_WINDOW:
|
||
return False
|
||
window = scores[-SILHOUETTE_DECLINE_WINDOW:]
|
||
first = window[0]
|
||
return all(s < first for s in window[1:])
|
||
|
||
|
||
def _best_silhouette_in_window(
|
||
snapshots: List[Tuple[int, np.ndarray, float, int]],
|
||
) -> Tuple[int, np.ndarray, float, int] | None:
|
||
"""在最近轮廓窗口内取轮廓系数最高的一轮;无快照时返回 None。"""
|
||
if not snapshots:
|
||
return None
|
||
pool = snapshots[-SILHOUETTE_DECLINE_WINDOW:]
|
||
return max(pool, key=lambda x: x[2])
|
||
|
||
|
||
def _resolve_peak_with_neighbor_noise_tiebreak(
|
||
snapshots: List[Tuple[int, np.ndarray, float, int]],
|
||
) -> Tuple[Tuple[int, np.ndarray, float, int], str | None]:
|
||
"""轮廓窗口早停:以窗口内峰值为中心,±2 邻轮若轮廓 ≥ 峰值−0.03 则与峰值一起按离群数择优。"""
|
||
pool = snapshots[-SILHOUETTE_DECLINE_WINDOW:]
|
||
center = max(pool, key=lambda x: x[2])
|
||
center_nn, _, center_sil, center_noise = center
|
||
threshold = center_sil - SILHOUETTE_NEIGHBOR_MARGIN
|
||
by_nn = {s[0]: s for s in snapshots}
|
||
candidates: Dict[int, Tuple[int, np.ndarray, float, int]] = {center_nn: center}
|
||
for delta in range(-SILHOUETTE_NEIGHBOR_RADIUS, SILHOUETTE_NEIGHBOR_RADIUS + 1):
|
||
if delta == 0:
|
||
continue
|
||
snap = by_nn.get(center_nn + delta)
|
||
if snap is not None and snap[2] >= threshold:
|
||
candidates[snap[0]] = snap
|
||
if len(candidates) == 1:
|
||
return center, None
|
||
chosen = min(candidates.values(), key=lambda s: (s[3], -s[2]))
|
||
note = (
|
||
f"邻轮复核:峰值 n_neighbors={center_nn}(轮廓{center_sil:.4f},离群{center_noise}),"
|
||
f"候选 {sorted(candidates)} 中择离群最少 → n_neighbors={chosen[0]}"
|
||
f"(轮廓{chosen[2]:.4f},离群{chosen[3]})"
|
||
)
|
||
return chosen, note
|
||
|
||
|
||
def _auto_tune_n_neighbors(
|
||
embeddings: np.ndarray,
|
||
sentences: List[str],
|
||
client: OpenAI,
|
||
stage: str,
|
||
) -> Tuple[np.ndarray, int, List[dict], Optional[float], int]:
|
||
rng = random.Random(UMAP_RANDOM_STATE)
|
||
n_neighbors = INITIAL_N_NEIGHBORS
|
||
tuning_log: List[dict] = []
|
||
labels: np.ndarray | None = None
|
||
min_cs = 2
|
||
silhouette_avg: float | None = None
|
||
silhouette_history: List[float] = []
|
||
# (n_neighbors, labels, silhouette, n_noise) 仅在有有效轮廓时入栈,供早停回退最优轮次
|
||
silhouette_snapshots: List[Tuple[int, np.ndarray, float, int]] = []
|
||
n = len(embeddings)
|
||
|
||
while True:
|
||
logger.info("[%s] 调参轮次 n_neighbors=%s", stage, n_neighbors)
|
||
labels, umap_emb, min_cs = _run_umap_hdbscan_capped(embeddings, n_neighbors)
|
||
n_clusters = _n_clusters_from_labels(labels)
|
||
n_noise = int((labels == -1).sum())
|
||
silhouette_avg = None
|
||
if n_clusters > 1:
|
||
mask = labels != -1
|
||
if int(mask.sum()) > 1 and len(set(labels[mask].tolist())) > 1:
|
||
silhouette_avg = float(
|
||
silhouette_score(umap_emb[mask], labels[mask])
|
||
)
|
||
logger.info(
|
||
"[%s] 簇数=%s 离群=%s 轮廓=%s",
|
||
stage,
|
||
n_clusters,
|
||
n_noise,
|
||
f"{silhouette_avg:.4f}" if silhouette_avg is not None else "N/A",
|
||
)
|
||
|
||
samples = _sample_from_clusters(sentences, labels, rng)
|
||
total_sample = sum(len(v) for v in samples.values())
|
||
log_entry: dict = {
|
||
"stage": stage,
|
||
"n_neighbors": n_neighbors,
|
||
"min_cluster_size": min_cs,
|
||
"n_clusters": n_clusters,
|
||
"n_noise": n_noise,
|
||
"silhouette": silhouette_avg,
|
||
"total_sample": total_sample,
|
||
}
|
||
|
||
if silhouette_avg is not None:
|
||
silhouette_history.append(silhouette_avg)
|
||
silhouette_snapshots.append(
|
||
(n_neighbors, labels.copy(), silhouette_avg, n_noise)
|
||
)
|
||
if _silhouette_decline_should_stop(silhouette_history):
|
||
window = silhouette_history[-SILHOUETTE_DECLINE_WINDOW:]
|
||
best, tiebreak_note = _resolve_peak_with_neighbor_noise_tiebreak(
|
||
silhouette_snapshots
|
||
)
|
||
best_nn, best_labels, best_sil, best_noise = best
|
||
n_neighbors = best_nn
|
||
labels = best_labels
|
||
silhouette_avg = best_sil
|
||
log_entry["silhouette_window"] = [round(s, 4) for s in window]
|
||
log_entry["n_noise"] = best_noise
|
||
stop_tail = (
|
||
f",回退至 n_neighbors={best_nn}(轮廓{best_sil:.4f},离群{best_noise})"
|
||
)
|
||
if tiebreak_note:
|
||
log_entry["neighbor_tiebreak"] = tiebreak_note
|
||
stop_tail = f";{tiebreak_note}"
|
||
log_entry["stop_reason"] = (
|
||
f"连续{SILHOUETTE_DECLINE_WINDOW}轮轮廓:后{SILHOUETTE_DECLINE_WINDOW - 1}个"
|
||
f"均低于窗口峰值{max(window):.4f}{stop_tail}"
|
||
)
|
||
tuning_log.append(log_entry)
|
||
logger.info(
|
||
"[%s] 轮廓窗口 %s,早停并回退 n_neighbors=%s 轮廓=%.4f 离群=%s%s",
|
||
stage,
|
||
log_entry["silhouette_window"],
|
||
best_nn,
|
||
best_sil,
|
||
best_noise,
|
||
f";{tiebreak_note}" if tiebreak_note else "",
|
||
)
|
||
break
|
||
|
||
if n_neighbors > MAX_N_NEIGHBORS:
|
||
best = _best_silhouette_in_window(silhouette_snapshots)
|
||
if best is not None:
|
||
best_nn, best_labels, best_sil, _ = best
|
||
n_neighbors = best_nn
|
||
labels = best_labels
|
||
silhouette_avg = best_sil
|
||
window = silhouette_history[-SILHOUETTE_DECLINE_WINDOW:]
|
||
log_entry["silhouette_window"] = [round(s, 4) for s in window]
|
||
log_entry["stop_reason"] = (
|
||
f"n_neighbors 超过上限 {MAX_N_NEIGHBORS},"
|
||
f"回退至 n_neighbors={best_nn}(轮廓{best_sil:.4f})"
|
||
)
|
||
logger.info(
|
||
"[%s] n_neighbors 超上限,回退 n_neighbors=%s 轮廓=%.4f",
|
||
stage,
|
||
best_nn,
|
||
best_sil,
|
||
)
|
||
else:
|
||
log_entry["stop_reason"] = (
|
||
f"n_neighbors 超过上限 {MAX_N_NEIGHBORS}(无有效轮廓快照,保留当前轮)"
|
||
)
|
||
tuning_log.append(log_entry)
|
||
break
|
||
|
||
ai_result = _ai_evaluate_cluster_samples(client, samples)
|
||
cross_count = ai_result["cross_similar_count"]
|
||
total = ai_result["total_sentences"] or total_sample or 1
|
||
ratio = cross_count / total
|
||
log_entry.update(
|
||
{
|
||
"cross_similar_count": cross_count,
|
||
"cross_similar_ratio": round(ratio, 4),
|
||
}
|
||
)
|
||
sil_ok = silhouette_avg is not None and silhouette_avg > SILHOUETTE_STOP_THRESHOLD
|
||
logger.info(
|
||
"[%s] AI: 跨类相似 %s/%s (%.1f%%,阈值≤%.0f%%) 轮廓=%s (停止需>%.1f)",
|
||
stage,
|
||
cross_count,
|
||
total,
|
||
ratio * 100,
|
||
CROSS_SIMILAR_RATIO_THRESHOLD * 100,
|
||
f"{silhouette_avg:.4f}" if silhouette_avg is not None else "N/A",
|
||
SILHOUETTE_STOP_THRESHOLD,
|
||
)
|
||
|
||
if ratio > CROSS_SIMILAR_RATIO_THRESHOLD:
|
||
log_entry["stop_reason"] = "跨类相似率>10%,继续调参"
|
||
tuning_log.append(log_entry)
|
||
n_neighbors += 1
|
||
time.sleep(0.3)
|
||
continue
|
||
|
||
if not sil_ok:
|
||
reason = (
|
||
"轮廓系数不可用,继续调参"
|
||
if silhouette_avg is None
|
||
else f"轮廓系数{silhouette_avg:.4f}≤{SILHOUETTE_STOP_THRESHOLD},继续调参"
|
||
)
|
||
log_entry["stop_reason"] = reason
|
||
tuning_log.append(log_entry)
|
||
n_neighbors += 1
|
||
time.sleep(0.3)
|
||
continue
|
||
|
||
log_entry["stop_reason"] = "跨类相似率≤10%且轮廓>0.6,停止调参"
|
||
tuning_log.append(log_entry)
|
||
break
|
||
|
||
assert labels is not None
|
||
return labels, n_neighbors, tuning_log, silhouette_avg, min_cs
|
||
|
||
|
||
def _cluster_fixed(
|
||
embeddings: np.ndarray, n_neighbors: int = STEP2_N_NEIGHBORS
|
||
) -> Tuple[np.ndarray, int, int]:
|
||
nn = min(n_neighbors, max(2, len(embeddings) - 1))
|
||
labels, _, min_cs = _run_umap_hdbscan_capped(embeddings, nn)
|
||
return labels, nn, min_cs
|
||
|
||
|
||
def _cluster_stage(
|
||
rows: List[EmbedRow],
|
||
*,
|
||
stage: str,
|
||
use_llm_tune: bool,
|
||
client: OpenAI | None,
|
||
) -> Tuple[np.ndarray, dict]:
|
||
n = len(rows)
|
||
meta: dict = {
|
||
"stage": stage,
|
||
"n_items": n,
|
||
"used_hdbscan": False,
|
||
"n_neighbors": None,
|
||
"tuning_log": [],
|
||
"silhouette": None,
|
||
}
|
||
if n == 0:
|
||
return np.array([], dtype=int), meta
|
||
if n < MIN_POINTS_FOR_HDBSCAN:
|
||
logger.info(
|
||
"[%s] n=%s < %s,逐条独立簇标签,不跑 HDBSCAN",
|
||
stage,
|
||
n,
|
||
MIN_POINTS_FOR_HDBSCAN,
|
||
)
|
||
meta["fallback"] = "per_item_labels_no_hdbscan"
|
||
return np.arange(n, dtype=int), meta
|
||
|
||
embeddings = _stack_embeddings(rows)
|
||
sentences = [r.embed_text for r in rows]
|
||
meta["used_hdbscan"] = True
|
||
|
||
if use_llm_tune:
|
||
if client is None:
|
||
raise RuntimeError(f"{stage} 需要 LLM 客户端")
|
||
labels, nn, tlog, sil, min_cs = _auto_tune_n_neighbors(
|
||
embeddings, sentences, client, stage
|
||
)
|
||
meta["n_neighbors"] = nn
|
||
meta["min_cluster_size"] = min_cs
|
||
meta["max_clusters"] = MAX_CLUSTERS
|
||
meta["tuning_log"] = tlog
|
||
meta["silhouette"] = sil
|
||
else:
|
||
labels, nn, min_cs = _cluster_fixed(embeddings)
|
||
meta["n_neighbors"] = nn
|
||
meta["min_cluster_size"] = min_cs
|
||
meta["max_clusters"] = MAX_CLUSTERS
|
||
meta["fallback"] = "fixed_params"
|
||
|
||
return labels, meta
|
||
|
||
|
||
def _init_cluster_db(conn: sqlite3.Connection) -> None:
|
||
conn.executescript(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS cluster_runs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
created_at TEXT NOT NULL,
|
||
job_id INTEGER NOT NULL,
|
||
embed_db TEXT NOT NULL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS cluster_assignments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
run_id INTEGER NOT NULL,
|
||
stage TEXT NOT NULL,
|
||
embedding_item_id INTEGER NOT NULL,
|
||
cluster_label INTEGER NOT NULL,
|
||
is_outlier INTEGER NOT NULL DEFAULT 0,
|
||
participates_downstream INTEGER NOT NULL DEFAULT 1,
|
||
parent_audience_cluster INTEGER,
|
||
n_neighbors INTEGER,
|
||
embed_text TEXT NOT NULL,
|
||
entity_type TEXT NOT NULL,
|
||
source_row INTEGER NOT NULL,
|
||
extraction_id INTEGER NOT NULL,
|
||
entity_index INTEGER NOT NULL,
|
||
audience TEXT,
|
||
aspect TEXT,
|
||
opinion TEXT,
|
||
category TEXT,
|
||
sentiment TEXT,
|
||
content TEXT NOT NULL,
|
||
FOREIGN KEY (run_id) REFERENCES cluster_runs(id)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS cluster_stage_meta (
|
||
run_id INTEGER NOT NULL,
|
||
stage TEXT NOT NULL,
|
||
meta_json TEXT NOT NULL,
|
||
PRIMARY KEY (run_id, stage)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS cluster_tuning_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
run_id INTEGER NOT NULL,
|
||
stage TEXT NOT NULL,
|
||
round_index INTEGER NOT NULL,
|
||
log_json TEXT NOT NULL,
|
||
FOREIGN KEY (run_id) REFERENCES cluster_runs(id)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_assign_run_stage ON cluster_assignments(run_id, stage);
|
||
CREATE INDEX IF NOT EXISTS idx_assign_cluster ON cluster_assignments(run_id, stage, cluster_label);
|
||
CREATE INDEX IF NOT EXISTS idx_assign_source ON cluster_assignments(run_id, source_row);
|
||
"""
|
||
)
|
||
|
||
|
||
def _resolve_source_csv(structured_db: Path, job_id: int) -> Path:
|
||
conn = sqlite3.connect(structured_db)
|
||
try:
|
||
row = conn.execute(
|
||
"SELECT source_file FROM analysis_jobs WHERE id = ?", (job_id,)
|
||
).fetchone()
|
||
finally:
|
||
conn.close()
|
||
if not row:
|
||
raise RuntimeError(f"analysis_jobs 无 job_id={job_id}")
|
||
p = Path(str(row[0]))
|
||
if p.is_file():
|
||
return p.resolve()
|
||
cand = PROJECT_ROOT / p
|
||
if cand.is_file():
|
||
return cand.resolve()
|
||
raise FileNotFoundError(f"找不到结构化来源 CSV: {row[0]}")
|
||
|
||
|
||
def _count_csv_reviews(csv_path: Path) -> int:
|
||
n = 0
|
||
with csv_path.open(encoding="utf-8-sig", newline="") as f:
|
||
reader = csv.DictReader(f)
|
||
for row in reader:
|
||
if (row.get("content") or "").strip():
|
||
n += 1
|
||
return n
|
||
|
||
|
||
def _keep_cluster_labels(
|
||
labels: np.ndarray,
|
||
source_rows: Sequence[int],
|
||
total_reviews: int,
|
||
*,
|
||
min_ratio: float = CLUSTER_MIN_REVIEW_RATIO,
|
||
) -> Tuple[set[int], Dict[int, int]]:
|
||
"""返回 (保留的簇标签, 被丢弃簇标签 -> 去重评论数)。"""
|
||
by_lab: Dict[int, set[int]] = defaultdict(set)
|
||
for lab, sr in zip(labels.tolist(), source_rows):
|
||
by_lab[int(lab)].add(int(sr))
|
||
if total_reviews <= 0:
|
||
return set(by_lab.keys()), {}
|
||
kept: set[int] = set()
|
||
dropped: Dict[int, int] = {}
|
||
for lab, srs in by_lab.items():
|
||
cnt = len(srs)
|
||
if cnt / total_reviews >= min_ratio:
|
||
kept.add(lab)
|
||
else:
|
||
dropped[lab] = cnt
|
||
return kept, dropped
|
||
|
||
|
||
def _reset_cluster_db(path: Path, *, reset: bool = True) -> sqlite3.Connection:
|
||
if reset and path.is_file():
|
||
path.unlink()
|
||
conn = sqlite3.connect(path)
|
||
_init_cluster_db(conn)
|
||
return conn
|
||
|
||
|
||
def _save_assignments(
|
||
conn: sqlite3.Connection,
|
||
run_id: int,
|
||
stage: str,
|
||
rows: List[EmbedRow],
|
||
labels: np.ndarray,
|
||
*,
|
||
n_neighbors: int | None,
|
||
outlier_participates: bool,
|
||
parent_clusters: List[int | None] | None = None,
|
||
) -> dict:
|
||
if parent_clusters is not None and len(parent_clusters) != len(rows):
|
||
raise ValueError("parent_clusters 长度与 rows 不一致")
|
||
source_rows = [r.source_row for r in rows]
|
||
stage_total_reviews = len(set(source_rows))
|
||
kept, dropped = _keep_cluster_labels(labels, source_rows, stage_total_reviews)
|
||
if dropped:
|
||
logger.info(
|
||
"[%s] 过滤小簇(去重评论占比 < %.0f%%,本步骤评论总数 %s):%s",
|
||
stage,
|
||
CLUSTER_MIN_REVIEW_RATIO * 100,
|
||
stage_total_reviews,
|
||
{
|
||
lab: f"{cnt}条({cnt / stage_total_reviews * 100:.1f}%)"
|
||
for lab, cnt in sorted(dropped.items())
|
||
if stage_total_reviews > 0
|
||
},
|
||
)
|
||
sql = """
|
||
INSERT INTO cluster_assignments (
|
||
run_id, stage, embedding_item_id, cluster_label, is_outlier,
|
||
participates_downstream, parent_audience_cluster, n_neighbors,
|
||
embed_text, entity_type, source_row, extraction_id, entity_index,
|
||
audience, aspect, opinion, category, sentiment, content
|
||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||
"""
|
||
saved = 0
|
||
for i, row in enumerate(rows):
|
||
lab = int(labels[i])
|
||
if lab not in kept:
|
||
continue
|
||
is_out = 1 if lab == -1 else 0
|
||
if is_out:
|
||
participates = 1 if outlier_participates else 0
|
||
else:
|
||
participates = 1
|
||
parent = None
|
||
if parent_clusters is not None:
|
||
parent = parent_clusters[i]
|
||
conn.execute(
|
||
sql,
|
||
(
|
||
run_id,
|
||
stage,
|
||
row.id,
|
||
lab,
|
||
is_out,
|
||
participates,
|
||
parent,
|
||
n_neighbors,
|
||
row.embed_text,
|
||
row.entity_type,
|
||
row.source_row,
|
||
row.extraction_id,
|
||
row.entity_index,
|
||
row.audience,
|
||
row.aspect,
|
||
row.opinion,
|
||
row.category,
|
||
row.sentiment,
|
||
row.content,
|
||
),
|
||
)
|
||
saved += 1
|
||
return {
|
||
"kept_clusters": sorted(kept),
|
||
"dropped_clusters": dropped,
|
||
"saved_rows": saved,
|
||
"min_review_ratio": CLUSTER_MIN_REVIEW_RATIO,
|
||
"stage_total_reviews": stage_total_reviews,
|
||
}
|
||
|
||
|
||
def _save_stage_meta(conn: sqlite3.Connection, run_id: int, stage: str, meta: dict) -> None:
|
||
conn.execute(
|
||
"""
|
||
INSERT OR REPLACE INTO cluster_stage_meta (run_id, stage, meta_json)
|
||
VALUES (?, ?, ?)
|
||
""",
|
||
(run_id, stage, json.dumps(meta, ensure_ascii=False)),
|
||
)
|
||
|
||
|
||
def _save_tuning_logs(
|
||
conn: sqlite3.Connection, run_id: int, stage: str, tuning_log: List[dict]
|
||
) -> None:
|
||
for i, entry in enumerate(tuning_log, start=1):
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO cluster_tuning_log (run_id, stage, round_index, log_json)
|
||
VALUES (?, ?, ?, ?)
|
||
""",
|
||
(run_id, stage, i, json.dumps(entry, ensure_ascii=False)),
|
||
)
|
||
|
||
|
||
def _top2_audience_clusters(
|
||
conn: sqlite3.Connection, run_id: int
|
||
) -> Tuple[List[int], Dict[int, int]]:
|
||
"""返回 (前两簇标签列表, source_row -> audience簇标签)。"""
|
||
cur = conn.execute(
|
||
"""
|
||
SELECT cluster_label, source_row
|
||
FROM cluster_assignments
|
||
WHERE run_id = ? AND stage = '1_audience'
|
||
AND is_outlier = 0 AND participates_downstream = 1
|
||
""",
|
||
(run_id,),
|
||
)
|
||
row_to_cluster: Dict[int, int] = {}
|
||
cluster_rows: Dict[int, set[int]] = {}
|
||
for lab, sr in cur.fetchall():
|
||
lab = int(lab)
|
||
sr = int(sr)
|
||
row_to_cluster[sr] = lab
|
||
cluster_rows.setdefault(lab, set()).add(sr)
|
||
ranked = sorted(
|
||
cluster_rows.items(), key=lambda x: len(x[1]), reverse=True
|
||
)
|
||
top2 = [lab for lab, _ in ranked[:2]]
|
||
return top2, row_to_cluster
|
||
|
||
|
||
def run_clustering(
|
||
*,
|
||
job_id: int | None = None,
|
||
structured_db: Path = STRUCTURED_DB,
|
||
embed_db: Path = EMBED_DB,
|
||
cluster_db: Path = CLUSTER_DB,
|
||
reset_db: bool = True,
|
||
) -> dict:
|
||
require_chat_api_key()
|
||
|
||
econn = sqlite3.connect(embed_db)
|
||
try:
|
||
jid = _resolve_job_id(job_id, econn, structured_db)
|
||
audience_rows = _load_embed_rows(econn, jid, "audience")
|
||
pain_rows = _load_embed_rows(econn, jid, "pain_point")
|
||
ao_rows = _load_embed_rows(econn, jid, "aspect_opinion")
|
||
finally:
|
||
econn.close()
|
||
|
||
client = create_chat_client()
|
||
cconn = _reset_cluster_db(cluster_db, reset=reset_db)
|
||
created = datetime.now(timezone.utc).isoformat()
|
||
try:
|
||
cur = cconn.execute(
|
||
"INSERT INTO cluster_runs (created_at, job_id, embed_db) VALUES (?,?,?)",
|
||
(created, jid, str(embed_db.resolve())),
|
||
)
|
||
run_id = int(cur.lastrowid)
|
||
|
||
csv_path = _resolve_source_csv(structured_db, jid)
|
||
total_reviews = _count_csv_reviews(csv_path)
|
||
logger.info("清洗后评论总数: %s", total_reviews)
|
||
|
||
# --- 1 audience ---
|
||
labels1, meta1 = _cluster_stage(
|
||
audience_rows, stage="1_audience", use_llm_tune=True, client=client
|
||
)
|
||
filt1 = _save_assignments(
|
||
cconn,
|
||
run_id,
|
||
"1_audience",
|
||
audience_rows,
|
||
labels1,
|
||
n_neighbors=meta1.get("n_neighbors"),
|
||
outlier_participates=False,
|
||
)
|
||
meta1["cluster_filter"] = filt1
|
||
_save_stage_meta(cconn, run_id, "1_audience", meta1)
|
||
if meta1.get("tuning_log"):
|
||
_save_tuning_logs(cconn, run_id, "1_audience", meta1["tuning_log"])
|
||
|
||
top2, row_to_aud = _top2_audience_clusters(cconn, run_id)
|
||
meta_top2 = {
|
||
"top2_audience_clusters": top2,
|
||
"per_cluster_source_rows": {
|
||
str(lab): len(_source_rows_for_audience_cluster(row_to_aud, lab))
|
||
for lab in top2
|
||
},
|
||
"step2_mode": "per_audience_cluster_llm_auto_tune",
|
||
}
|
||
_save_stage_meta(cconn, run_id, "step2_filter", meta_top2)
|
||
logger.info("Audience 前两簇(将分别聚类): %s", top2)
|
||
|
||
step2_counts: Dict[str, int] = {}
|
||
for aud_lab in top2:
|
||
step2_counts.update(
|
||
_cluster_step2_for_audience(
|
||
cconn,
|
||
run_id,
|
||
audience_cluster=aud_lab,
|
||
pain_rows=pain_rows,
|
||
ao_rows=ao_rows,
|
||
row_to_aud=row_to_aud,
|
||
client=client,
|
||
)
|
||
)
|
||
|
||
# --- 3a pain global ---
|
||
labels3a, meta3a = _cluster_stage(
|
||
pain_rows, stage="3a_pain_global", use_llm_tune=True, client=client
|
||
)
|
||
filt3a = _save_assignments(
|
||
cconn,
|
||
run_id,
|
||
"3a_pain_global",
|
||
pain_rows,
|
||
labels3a,
|
||
n_neighbors=meta3a.get("n_neighbors"),
|
||
outlier_participates=True,
|
||
)
|
||
meta3a["cluster_filter"] = filt3a
|
||
_save_stage_meta(cconn, run_id, "3a_pain_global", meta3a)
|
||
if meta3a.get("tuning_log"):
|
||
_save_tuning_logs(cconn, run_id, "3a_pain_global", meta3a["tuning_log"])
|
||
|
||
# --- 3b aspect_opinion by sentiment ---
|
||
step3b_counts = _cluster_aspect_opinion_sentiment_stages(
|
||
cconn,
|
||
run_id,
|
||
ao_rows,
|
||
stage_for=_stage_3b,
|
||
outlier_participates=True,
|
||
parent_audience_cluster=None,
|
||
skipped_reason_prefix="no_aspect_opinion",
|
||
client=client,
|
||
)
|
||
|
||
cconn.commit()
|
||
summary = {
|
||
"run_id": run_id,
|
||
"job_id": jid,
|
||
"total_reviews": total_reviews,
|
||
"min_cluster_review_ratio": CLUSTER_MIN_REVIEW_RATIO,
|
||
"cluster_db": str(cluster_db.resolve()),
|
||
"top2_audience_clusters": top2,
|
||
"counts": {
|
||
"1_audience": len(audience_rows),
|
||
**step2_counts,
|
||
"3a_pain_global": len(pain_rows),
|
||
**step3b_counts,
|
||
},
|
||
}
|
||
logger.info("聚类完成: %s", summary)
|
||
return summary
|
||
finally:
|
||
cconn.close()
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="VOC 向量聚类")
|
||
parser.add_argument(
|
||
"--job-id",
|
||
type=int,
|
||
default=None,
|
||
help="不指定则自动使用 voc_structured.sqlite 中最新 analysis_jobs.id",
|
||
)
|
||
parser.add_argument("--structured-db", type=Path, default=STRUCTURED_DB)
|
||
parser.add_argument("--embed-db", type=Path, default=EMBED_DB)
|
||
parser.add_argument("--cluster-db", type=Path, default=CLUSTER_DB)
|
||
args = parser.parse_args()
|
||
summary = run_clustering(
|
||
job_id=args.job_id,
|
||
structured_db=args.structured_db,
|
||
embed_db=args.embed_db,
|
||
cluster_db=args.cluster_db,
|
||
)
|
||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|