(二十二)完成统计、保存与图表输出
1. 沿书籍轴与标签轴分别统计
# 项目文件:analysis_project/src/readingarray/stats.py
# 先沿标签轴得到每本书的标签数,再按状态筛选;沿书籍轴得到每个标签的覆盖数。
import numpy as np
def summarize(catalog):
tag_counts = catalog.tags.sum(axis=1)
finished = catalog.finished
group_means = {}
for name, mask in (("finished", finished), ("unfinished", ~finished)):
values = tag_counts[mask]
group_means[name] = float(values.mean()) if values.size else None
return {
"books": len(catalog.titles),
"finished": int(finished.sum()),
"finished_rate": float(finished.mean()),
"tag_assignments": int(tag_counts.sum()),
"mean_tags": float(tag_counts.mean()),
"tag_counts": tag_counts.tolist(),
"vocabulary": list(catalog.vocabulary),
"tag_frequency": catalog.tags.sum(axis=0).tolist(),
"mean_tags_by_status": group_means,
# 两个标签对应列的乘积求和,得到同时含有它们的书籍数。
"cooccurrence": (catalog.tags.T @ catalog.tags).tolist(),
}
def save_arrays(path, catalog):
# 字符串使用 Unicode 数组,读取时可以关闭 pickle。
np.savez_compressed(
path,
titles=np.asarray(catalog.titles, dtype=np.str_),
vocabulary=np.asarray(catalog.vocabulary, dtype=np.str_),
finished=catalog.finished,
tags=catalog.tags,
)tags.sum(axis=1) 合并标签轴,得到每本书的标签数 [2,1,1,1,1,1];tags.sum(axis=0) 合并书籍轴,得到每个标签的覆盖数,这份数据中七个标签恰好都为 1。
finished 中有两个 True,sum 得到 2,mean 把 True 按 1、False 按 0 统计,得到 2/6=1/3。已读两本的标签均值为 1,未读四本为 5/4=1.25;没有对应状态的记录时,报告用 null 表达没有这组均值。
2. 用矩阵乘法统计标签共现
令标签矩阵为 T,shape 为 (6,7)。T.T @ T 得到 (7,7),其中第 i 行第 j 列的数是 Σ T[书,i]×T[书,j]。一本书同时含两个标签时,乘积才为 1,所以求和后正好是共同出现的书籍数。
“基础”和“语法”都在第一本书,交叉位置为 1;“基础”和“数值”不在同一本书,交叉位置为 0。对角线相当于同一列自己相乘,仍然是每个标签覆盖的书籍数。
3. 从命令入口生成完整结果
将目录的绘制函数也接入同一个 plots.py。barh 用类别作为纵轴,invert_yaxis 让词表第一项显示在最上方:
# 续写同一文件:analysis_project/src/readingarray/plots.py
def plot_catalog(catalog, report, path):
with plt.rc_context(STYLE):
fig, axes = plt.subplots(1, 2, figsize=(11, 4.5), layout="constrained")
try:
counts = [report["finished"], report["books"] - report["finished"]]
axes[0].bar(["已读", "未读"], counts, color=["#087F82", "#4657B5"])
axes[0].set(title=f"{report['books']} 本书的阅读状态", ylabel="书籍数")
axes[0].set_yticks(range(max(counts) + 1))
axes[1].barh(catalog.vocabulary, report["tag_frequency"], color="#A85D11")
axes[1].invert_yaxis()
axes[1].set(title="每个标签覆盖的书籍数", xlabel="书籍数")
axes[1].set_xticks(range(max(report["tag_frequency"], default=0) + 1))
fig.suptitle("采集目录统计", fontsize=17, fontweight="bold")
fig.savefig(path, bbox_inches="tight", pad_inches=0.15)
finally:
plt.close(fig)# 项目文件:analysis_project/src/readingarray/cli.py
# 读取采集 CSV,保存清洗说明、统计、数组归档与同一组数据的图表。
import argparse
import csv
import json
import sys
from pathlib import Path
from .arrays import build_arrays
from .plots import plot_catalog
from .records import load_books
from .stats import save_arrays, summarize
def analyze(source, output):
books, issues = load_books(source)
catalog = build_arrays(books)
report = {**summarize(catalog), "issues": issues}
output.mkdir(parents=True, exist_ok=True)
(output / "summary.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
encoding="utf-8",
)
save_arrays(output / "catalog_arrays.npz", catalog)
plot_catalog(catalog, report, output / "catalog-summary.svg")
return report
def main(argv=None):
parser = argparse.ArgumentParser(description="分析采集目录中的阅读状态与标签")
parser.add_argument("source", type=Path, nargs="?", default=Path("data/books.csv"))
parser.add_argument("--output", type=Path, default=Path("output"))
args = parser.parse_args(argv)
try:
report = analyze(args.source, args.output)
except (OSError, ValueError, csv.Error) as error:
print(f"分析失败:{error}", file=sys.stderr)
return 1
print(
f"书籍 {report['books']};已读 {report['finished']};阅读比例 {report['finished_rate']:.2%}"
)
print(f"标签归属 {report['tag_assignments']};每本平均 {report['mean_tags']:.6f}")
for issue in report["issues"]:
print(f"清洗记录:{issue}")
print(f"结果目录:{args.output}")
return 0analyze 按读取、建数组、统计、保存与绘制的顺序组合。summary.json 保存普通 Python 数值与列表,catalog_arrays.npz 保留标签顺序和数组,catalog-summary.svg 显示同一组统计。
# 项目文件:analysis_project/src/readingarray/__init__.py
# 导入包不读取 CSV,也不建立图表;实际动作从命令入口进入。
"""书籍目录的 NumPy 分析示例。"""# 项目文件:analysis_project/src/readingarray/__main__.py
# python -m readingarray 与 reading-analyze 调用相同入口。
from .cli import main
raise SystemExit(main())# 在 analysis_project 中分析配套的实际采集结果。
uv run reading-analyze data/books.csv --output output
# 替换成自己的采集 CSV 后,同样走读取、清洗、统计与绘图过程。
uv run python -m readingarray data/books.csv --output output
# 核对字段错误、行列对应、训练尺度、梯度、归档与完整命令入口。
uv run python -m pytest
前两行输出为“书籍 6;已读 2;阅读比例 33.33%”与“标签归属 7;每本平均 1.166667”。清洗后若有记录未参与,紧接着逐条显示原因,并写入报告的 issues 字段。
4. pandas 保留表格的列名
当任务主要围绕命名列、分组、连接多张表进行时,pandas 的 DataFrame 可以继续组织记录;NumPy 则着重处理已经明确轴与 dtype 的数组。可以先用 DataFrame 选择数值列,再转成 ndarray。
# 安装项目声明的 table 扩展,再运行表格与数组的对照。
uv sync --locked --extra table
uv run python ../demos/pandas_bridge.py
# 项目文件:demos/pandas_bridge.py
# DataFrame 保留列名;选择同类数值列后再转 NumPy,与前面数组统计比较。
from pathlib import Path
import numpy as np
import pandas as pd
source = Path(__file__).resolve().parents[1] / "analysis_project/data/books.csv"
frame = pd.read_csv(
source, dtype={"title": "string", "finished": "int64", "tags": "string"}
)
print("列名", frame.columns.tolist(), "行列", frame.shape)
finished = frame["finished"].to_numpy(dtype=np.int64, copy=True)
print("已读", int(finished.sum()), "比例", round(float(finished.mean()), 6))
print("状态分组", frame.groupby("finished").size().to_dict())read_csv 根据列名指定类型,frame.shape 为 (6,3);frame["finished"] 选择一列,to_numpy 交回数组。copy=True 在这里建立独立结果,避免把后面的修改与表格数据混在一起。groupby 按状态分组,size 统计各组行数,得到未读 4、已读 2。
tags 此时仍是 JSON 文本,读取 CSV 不会自动把它变成已经清洗的标签集合;需要回到 records.py 的解析步骤。pandas 表格与列