Python 现代开发学习笔记

(二十二)完成统计、保存与图表输出

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 0

analyze 按读取、建数组、统计、保存与绘制的顺序组合。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 字段。

实际采集的六本书中两本已读四本未读,七个标签分别覆盖一本书,图表与JSON统计一致

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 表格与列

笔记正文与全部题解 · Ctrl / ⌘ K