306 lines
12 KiB
Python
306 lines
12 KiB
Python
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Optional, Tuple
|
|
|
|
import numpy as np
|
|
import matplotlib.pyplot as plt
|
|
|
|
# 尝试可选依赖
|
|
_umap_available = False
|
|
try:
|
|
import umap # type: ignore
|
|
_umap_available = True
|
|
except Exception:
|
|
_umap_available = False
|
|
|
|
from sklearn.decomposition import PCA
|
|
from sklearn.manifold import TSNE
|
|
from sklearn.metrics import silhouette_score, pairwise_distances
|
|
from sklearn.preprocessing import StandardScaler
|
|
|
|
|
|
def load_embeddings_json(path: str) -> np.ndarray:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
emb = np.asarray(data, dtype=np.float32)
|
|
if emb.ndim != 2:
|
|
raise ValueError(f"embeddings 不是二维数组: shape={emb.shape}")
|
|
return emb
|
|
|
|
|
|
def load_labels_json(path: str) -> np.ndarray:
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
labels = np.asarray(data, dtype=np.int64)
|
|
if labels.ndim != 1:
|
|
raise ValueError(f"labels 不是一维数组: shape={labels.shape}")
|
|
return labels
|
|
|
|
|
|
def subsample(X: np.ndarray, y: np.ndarray, max_points: Optional[int], seed: int = 42) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
n = X.shape[0]
|
|
idx = np.arange(n)
|
|
if max_points is not None and n > max_points:
|
|
rng = np.random.default_rng(seed)
|
|
sel = rng.choice(n, size=max_points, replace=False)
|
|
sel = np.sort(sel)
|
|
return X[sel], y[sel], sel
|
|
return X, y, idx
|
|
|
|
|
|
def diagnostics(X: np.ndarray, y: np.ndarray, reduced2d: Optional[np.ndarray] = None, method: str = "pca") -> str:
|
|
# 基本统计
|
|
N, D = X.shape
|
|
means = X.mean(axis=0)
|
|
stds = X.std(axis=0)
|
|
mean_norm = float(np.linalg.norm(means))
|
|
mean_std = float(stds.mean())
|
|
zero_var_ratio = float((stds == 0).mean())
|
|
|
|
# 余弦相似度(抽样,避免 O(N^2))
|
|
max_pairs = 20000
|
|
rng = np.random.default_rng(0)
|
|
if N > 1:
|
|
sample_size = min(N, int(np.sqrt(max_pairs)) + 1)
|
|
idx = rng.choice(N, size=sample_size, replace=False)
|
|
Xs = X[idx]
|
|
# 归一化以计算余弦
|
|
Xn = Xs / (np.linalg.norm(Xs, axis=1, keepdims=True) + 1e-12)
|
|
cos = Xn @ Xn.T
|
|
iu = np.triu_indices_from(cos, k=1)
|
|
cos_vals = cos[iu]
|
|
cos_mean = float(cos_vals.mean()) if cos_vals.size > 0 else float("nan")
|
|
cos_std = float(cos_vals.std()) if cos_vals.size > 0 else float("nan")
|
|
else:
|
|
cos_mean = float("nan")
|
|
cos_std = float("nan")
|
|
|
|
# silhouette(类内/类间可分性),需要至少2类且每类>=2个点
|
|
sil = float("nan")
|
|
unique, counts = np.unique(y, return_counts=True)
|
|
if unique.size >= 2 and (counts >= 2).all():
|
|
# 为避免过大计算,对子集做
|
|
subN = min(N, 5000)
|
|
if N > subN:
|
|
idx = rng.choice(N, size=subN, replace=False)
|
|
Xs, ys = X[idx], y[idx]
|
|
else:
|
|
Xs, ys = X, y
|
|
# 用欧氏距离
|
|
try:
|
|
sil = float(silhouette_score(Xs, ys, metric="euclidean"))
|
|
except Exception:
|
|
sil = float("nan")
|
|
|
|
# PCA前两维解释率(若提供reduced2d且method=pca,则更有意义)
|
|
pca_explained_2 = float("nan")
|
|
try:
|
|
pca = PCA(n_components=2, random_state=0)
|
|
pca.fit(X)
|
|
pca_explained_2 = float(pca.explained_variance_ratio_.sum())
|
|
except Exception:
|
|
pass
|
|
|
|
lines = [
|
|
f"N={N}, D={D}, 类别数={unique.size}, 每类样本数范围=[{counts.min() if counts.size>0 else 0}, {counts.max() if counts.size>0 else 0}]",
|
|
f"均值向量范数={mean_norm:.6f}, 维度平均标准差={mean_std:.6f}, 零方差维度占比={zero_var_ratio*100:.2f}%",
|
|
f"余弦相似度: 均值={cos_mean:.6f}, 标准差={cos_std:.6f} (越接近1且方差小,越可能坍塌)",
|
|
f"Silhouette 分数={sil:.4f} (接近0或为负,类间不可分)",
|
|
f"PCA前两维累计解释率={pca_explained_2*100:.2f}%",
|
|
f"降维方法={method}",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def reduce_dim(X: np.ndarray, method: str, seed: int, tsne_perplexity: int, umap_n_neighbors: int, umap_min_dist: float) -> np.ndarray:
|
|
if method == "pca":
|
|
# 标准化后做PCA,通常更稳定
|
|
Xs = StandardScaler(with_mean=True, with_std=True).fit_transform(X)
|
|
comp = PCA(n_components=2, random_state=seed)
|
|
Z = comp.fit_transform(Xs)
|
|
return Z
|
|
elif method == "tsne":
|
|
# t-SNE 对规模敏感,建议先PCA到50维再t-SNE
|
|
d = min(50, X.shape[1])
|
|
Xp = PCA(n_components=d, random_state=seed).fit_transform(X)
|
|
ts = TSNE(n_components=2, perplexity=tsne_perplexity, learning_rate="auto", init="pca", random_state=seed, n_iter=1000)
|
|
Z = ts.fit_transform(Xp)
|
|
return Z
|
|
elif method == "umap":
|
|
if not _umap_available:
|
|
raise RuntimeError("未安装 umap-learn,请先: pip install umap-learn")
|
|
reducer = umap.UMAP(n_components=2, n_neighbors=umap_n_neighbors, min_dist=umap_min_dist, random_state=seed, metric="euclidean")
|
|
Z = reducer.fit_transform(X)
|
|
return Z
|
|
else:
|
|
raise ValueError(f"未知降维方法: {method}")
|
|
|
|
|
|
def plot_2d(Z: np.ndarray, y: np.ndarray, title: str, out_path: Optional[str] = None, show: bool = True) -> None:
|
|
"""
|
|
绘制2D降维散点图
|
|
|
|
Args:
|
|
Z: 降维后的2D坐标
|
|
y: 类别标签
|
|
title: 图表标题
|
|
out_path: 保存路径(可选)
|
|
show: 是否显示窗口(默认True,训练脚本中应设为False)
|
|
"""
|
|
plt.figure(figsize=(8, 7), dpi=120)
|
|
classes = np.unique(y)
|
|
# 构建调色板
|
|
cmap = plt.get_cmap("tab20")
|
|
colors = [cmap(i % 20) for i in range(len(classes))]
|
|
|
|
for i, c in enumerate(classes):
|
|
mask = (y == c)
|
|
plt.scatter(Z[mask, 0], Z[mask, 1], s=8, c=[colors[i]], label=str(c), alpha=0.7, edgecolors='none')
|
|
|
|
plt.title(title)
|
|
plt.xlabel("Dim 1")
|
|
plt.ylabel("Dim 2")
|
|
if len(classes) <= 20:
|
|
plt.legend(markerscale=2, frameon=True, fontsize=8)
|
|
plt.tight_layout()
|
|
if out_path:
|
|
plt.savefig(out_path, bbox_inches="tight")
|
|
if show:
|
|
plt.show()
|
|
|
|
|
|
def visualize_embeddings_from_files(embeddings_path: str,
|
|
labels_path: str,
|
|
output_dir: str,
|
|
method: str = "pca",
|
|
max_points: Optional[int] = None,
|
|
seed: int = 42,
|
|
**kwargs) -> str:
|
|
"""
|
|
从文件加载并可视化embeddings(用于训练脚本调用)
|
|
|
|
Args:
|
|
embeddings_path: embeddings.json路径
|
|
labels_path: labels.json路径
|
|
output_dir: 输出目录
|
|
method: 降维方法 (pca/tsne/umap)
|
|
max_points: 抽样上限
|
|
seed: 随机种子
|
|
**kwargs: 其他降维参数
|
|
|
|
Returns:
|
|
输出的PNG图片路径
|
|
"""
|
|
print("=" * 60)
|
|
print(f"开始可视化 embeddings")
|
|
print(f"降维方法: {method}")
|
|
print("=" * 60)
|
|
|
|
# 加载数据
|
|
print(f"加载数据: {embeddings_path}")
|
|
X = load_embeddings_json(embeddings_path)
|
|
y = load_labels_json(labels_path)
|
|
|
|
# 数据对齐
|
|
if X.shape[0] != y.shape[0]:
|
|
n = min(X.shape[0], y.shape[0])
|
|
print(f"警告: 数据不一致,截断到 {n}")
|
|
X, y = X[:n], y[:n]
|
|
|
|
# 抽样
|
|
Xs, ys, _ = subsample(X, y, max_points, seed=seed)
|
|
if Xs.shape[0] < X.shape[0]:
|
|
print(f"已抽样: {Xs.shape[0]}/{X.shape[0]}")
|
|
|
|
# 降维参数
|
|
tsne_perplexity = kwargs.get('tsne_perplexity', 30)
|
|
umap_n_neighbors = kwargs.get('umap_n_neighbors', 15)
|
|
umap_min_dist = kwargs.get('umap_min_dist', 0.1)
|
|
|
|
# 降维
|
|
print(f"执行降维: {method}")
|
|
Z = reduce_dim(Xs, method, seed, tsne_perplexity, umap_n_neighbors, umap_min_dist)
|
|
|
|
# 诊断
|
|
print("\n==== 诊断信息 ====")
|
|
diag_info = diagnostics(Xs, ys, reduced2d=Z, method=method)
|
|
print(diag_info)
|
|
|
|
# 保存诊断信息
|
|
diag_path = os.path.join(output_dir, f"embedding_{method}_diagnostics.txt")
|
|
with open(diag_path, 'w', encoding='utf-8') as f:
|
|
f.write(diag_info)
|
|
print(f"✓ 诊断信息已保存: {diag_path}")
|
|
|
|
# 绘图
|
|
out_png = os.path.join(output_dir, f"embedding_{method}_2d.png")
|
|
title = f"Embedding {method.upper()} 2D (N={Xs.shape[0]})"
|
|
|
|
# 训练脚本调用时不显示窗口,只保存图片
|
|
plot_2d(Z, ys, title, out_path=out_png, show=False)
|
|
plt.close('all') # 关闭所有图形
|
|
|
|
print(f"✓ 可视化图已保存: {out_png}")
|
|
print("=" * 60)
|
|
|
|
return out_png
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="可视化高维 embedding 并进行坍塌诊断")
|
|
# parser.add_argument("--embeddings", type=str, default=os.path.join("DishClassification/faiss_index", "embeddings.json"), help="embeddings.json 路径")
|
|
parser.add_argument("--embeddings", type=str, default=os.path.join("WholeIngredientRecognition/faiss_index", "embeddings.json"), help="embeddings.json 路径")
|
|
# parser.add_argument("--embeddings", type=str, default=os.path.join("ProcessedIngredientRecognition/faiss_index", "embeddings.json"), help="embeddings.json 路径")
|
|
# parser.add_argument("--labels", type=str, default=os.path.join("DishClassification/faiss_index", "labels.json"), help="labels.json 路径")
|
|
parser.add_argument("--labels", type=str, default=os.path.join("WholeIngredientRecognition/faiss_index", "labels.json"), help="labels.json 路径")
|
|
# parser.add_argument("--labels", type=str, default=os.path.join("ProcessedIngredientRecognition/faiss_index", "labels.json"), help="labels.json 路径")
|
|
parser.add_argument("--method", type=str, default="pca", choices=["pca", "tsne", "umap"], help="降维方法")
|
|
parser.add_argument("--seed", type=int, default=42)
|
|
parser.add_argument("--max_points", type=int, default=None, help="抽样上限,避免t-SNE/UMAP过慢;None为全量")
|
|
parser.add_argument("--tsne_perplexity", type=int, default=30, help="t-SNE perplexity")
|
|
parser.add_argument("--umap_n_neighbors", type=int, default=15, help="UMAP n_neighbors")
|
|
parser.add_argument("--umap_min_dist", type=float, default=0.1, help="UMAP min_dist")
|
|
parser.add_argument("--no_center_scale", action="store_true", help="禁用标准化(默认PCA会标准化)")
|
|
args = parser.parse_args()
|
|
|
|
if not os.path.exists(args.embeddings):
|
|
print(f"找不到 embeddings: {args.embeddings}", file=sys.stderr)
|
|
sys.exit(1)
|
|
if not os.path.exists(args.labels):
|
|
print(f"找不到 labels: {args.labels}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print("加载数据...")
|
|
X = load_embeddings_json(args.embeddings)
|
|
y = load_labels_json(args.labels)
|
|
|
|
if X.shape[0] != y.shape[0]:
|
|
n = min(X.shape[0], y.shape[0])
|
|
print(f"警告: embeddings与labels数量不一致, 截断到最小长度 {n}。", file=sys.stderr)
|
|
X = X[:n]
|
|
y = y[:n]
|
|
|
|
# 抽样(t-SNE/UMAP 推荐抽样)
|
|
Xs, ys, sel_idx = subsample(X, y, args.max_points, seed=args.seed)
|
|
if Xs.shape[0] < X.shape[0]:
|
|
print(f"已抽样: {Xs.shape[0]}/{X.shape[0]}")
|
|
|
|
# 降维
|
|
print(f"降维方法: {args.method}")
|
|
Z = reduce_dim(Xs, args.method, args.seed, args.tsne_perplexity, args.umap_n_neighbors, args.umap_min_dist)
|
|
|
|
# 诊断
|
|
print("\n==== 诊断信息 ====")
|
|
print(diagnostics(Xs, ys, reduced2d=Z, method=args.method))
|
|
|
|
# 绘图
|
|
out_png = os.path.join(os.path.dirname(args.embeddings), f"embedding_{args.method}_2d.png")
|
|
title = f"Embedding {args.method.upper()} 2D (N={Xs.shape[0]})"
|
|
plot_2d(Z, ys, title, out_path=out_png)
|
|
print(f"已保存图像: {out_png}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |