236 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""特征提取与日志聚类。
核心为纯 Python 实现(无第三方依赖),保证离线可运行;生产环境可通过
``use_sklearn=True`` 切换到 scikit-learn 的 TF-IDF + DBSCAN 后端。
"""
from __future__ import annotations
import math
import uuid
from dataclasses import dataclass
from typing import Optional
from .models import FaultLog
from .parser import templatize, tokenize
# ---------------------------------------------------------------------------
# TF-IDF
# ---------------------------------------------------------------------------
class TfidfVectorizer:
"""极简 TF-IDF 向量化器。"""
def __init__(self):
self._idf: dict[str, float] = {}
self._vocab: list[str] = []
def fit(self, documents: list[list[str]]) -> "TfidfVectorizer":
n = len(documents)
if n == 0:
self._vocab = []
self._idf = {}
return self
df: dict[str, int] = {}
for doc in documents:
for token in set(doc):
df[token] = df.get(token, 0) + 1
self._vocab = sorted(df.keys())
self._idf = {
token: math.log((1 + n) / (1 + df[token])) + 1.0 for token in self._vocab
}
return self
def transform(self, documents: list[list[str]]) -> list[dict[str, float]]:
vectors: list[dict[str, float]] = []
for doc in documents:
tf: dict[str, float] = {}
if doc:
for token in doc:
tf[token] = tf.get(token, 0.0) + 1.0
norm = math.sqrt(sum(v * v for v in tf.values())) or 1.0
vectors.append(
{token: (tf.get(token, 0.0) / norm) * self._idf.get(token, 0.0) for token in tf}
)
return vectors
def cosine_similarity(a: dict[str, float], b: dict[str, float]) -> float:
"""两个稀疏向量L2 归一化后)的余弦相似度。"""
if not a or not b:
return 0.0
dot = 0.0
if len(a) <= len(b):
for k, v in a.items():
dot += v * b.get(k, 0.0)
else:
for k, v in b.items():
dot += v * a.get(k, 0.0)
return max(0.0, min(1.0, dot))
def cosine_distance(a: dict[str, float], b: dict[str, float]) -> float:
return 1.0 - cosine_similarity(a, b)
# ---------------------------------------------------------------------------
# DBSCAN余弦距离
# ---------------------------------------------------------------------------
@dataclass
class ClusterResult:
"""聚类结果。"""
labels: list[int] # -1 表示噪声
cluster_ids: dict[int, str] # 簇索引 -> 簇 id
representative_vectors: dict[str, dict[str, float]] # 簇 id -> 代表向量
def dbscan(
vectors: list[dict[str, float]],
eps: float = 0.75,
min_samples: int = 5,
) -> ClusterResult:
"""DBSCAN余弦距离纯 Python 实现O(n^2)。
``eps`` 为余弦距离阈值(等价于相似度 >= 1 - eps
"""
n = len(vectors)
if n == 0:
return ClusterResult(labels=[], cluster_ids={}, representative_vectors={})
# 邻接表(核心点判定:邻域内点数 >= min_samples含自身
neighbors: list[list[int]] = [[] for _ in range(n)]
for i in range(n):
neighbors[i].append(i)
for j in range(i + 1, n):
if cosine_distance(vectors[i], vectors[j]) <= eps:
neighbors[i].append(j)
neighbors[j].append(i)
labels = [-1] * n
visited = [False] * n
cluster = 0
for i in range(n):
if visited[i]:
continue
visited[i] = True
if len(neighbors[i]) < min_samples:
labels[i] = -1 # 噪声
continue
# 核心点:扩展新簇
labels[i] = cluster
seeds = list(neighbors[i])
for q in seeds:
if not visited[q]:
visited[q] = True
if len(neighbors[q]) >= min_samples:
seeds.extend(neighbors[q])
if labels[q] == -1:
labels[q] = cluster
cluster += 1
cluster_ids: dict[int, str] = {}
representative_vectors: dict[str, dict[str, float]] = {}
for c in range(cluster):
members = [i for i in range(n) if labels[i] == c]
cid = f"c-{uuid.uuid4().hex[:8]}"
cluster_ids[c] = cid
representative_vectors[cid] = _centroid([vectors[i] for i in members])
return ClusterResult(labels=labels, cluster_ids=cluster_ids, representative_vectors=representative_vectors)
def _centroid(vectors: list[dict[str, float]]) -> dict[str, float]:
if not vectors:
return {}
acc: dict[str, float] = {}
for vec in vectors:
for k, v in vec.items():
acc[k] = acc.get(k, 0.0) + v
n = len(vectors)
return {k: v / n for k, v in acc.items()}
# ---------------------------------------------------------------------------
# 增量聚类引擎
# ---------------------------------------------------------------------------
class ClusterEngine:
"""日志聚类引擎:特征提取 + DBSCAN + 增量分配。"""
def __init__(self, eps: float = 0.75, min_samples: int = 5, use_sklearn: bool = False):
self.eps = eps
self.min_samples = min_samples
self.use_sklearn = use_sklearn
# 已存在簇的代表向量cluster_id -> 向量
self._representatives: dict[str, dict[str, float]] = {}
def _vectors(self, logs: list[FaultLog]) -> list[dict[str, float]]:
docs = [tokenize(templatize(log.message)) for log in logs]
if self.use_sklearn:
return self._sklearn_vectors(docs)
vectorizer = TfidfVectorizer().fit(docs)
return vectorizer.transform(docs)
@staticmethod
def _sklearn_vectors(docs: list[list[str]]) -> list[dict[str, float]]:
try:
from sklearn.feature_extraction.text import TfidfVectorizer as SkTfidf
except ImportError as exc: # pragma: no cover
raise RuntimeError("use_sklearn=True 需要安装 scikit-learn") from exc
joined = [" ".join(doc) for doc in docs]
if not any(joined):
return [{} for _ in docs]
m = SkTfidfVectorizer().fit_transform(joined)
rows = m.toarray()
return [{str(i): float(v) for i, v in enumerate(row) if v != 0.0} for row in rows]
def cluster_batch(self, logs: list[FaultLog]) -> dict[str, str]:
"""对一批日志聚类,返回 {fault_log_id: cluster_id}。
优先增量匹配已有簇,剩余未匹配日志再做一次 DBSCAN 形成新簇。
"""
assignment: dict[str, str] = {}
if not logs:
return assignment
vectors = self._vectors(logs)
unresolved_idx: list[int] = []
for i, log in enumerate(logs):
matched = self._match_representative(vectors[i])
if matched is not None:
assignment[log.fault_log_id] = matched
else:
unresolved_idx.append(i)
if unresolved_idx:
sub_vectors = [vectors[i] for i in unresolved_idx]
result = dbscan(sub_vectors, self.eps, self.min_samples)
for local_idx, global_idx in enumerate(unresolved_idx):
label = result.labels[local_idx]
if label == -1:
# 噪声日志独立成单点簇
cid = f"c-{uuid.uuid4().hex[:8]}"
self._representatives[cid] = sub_vectors[local_idx]
assignment[logs[global_idx].fault_log_id] = cid
else:
cid = result.cluster_ids[label]
assignment[logs[global_idx].fault_log_id] = cid
# 更新新簇代表向量
for cid, vec in result.representative_vectors.items():
self._representatives[cid] = vec
return assignment
def _match_representative(self, vector: dict[str, float]) -> Optional[str]:
if not vector or not self._representatives:
return None
best_cid: Optional[str] = None
best_sim = 1.0 - self.eps # 相似度阈值
for cid, rep in self._representatives.items():
sim = cosine_similarity(vector, rep)
if sim > best_sim:
best_sim = sim
best_cid = cid
return best_cid