ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python+Pandas:图书馆借阅数据分析与可视化实战

Python+Pandas:图书馆借阅数据分析与可视化实战

简介:这是一份面向图书馆信息化从业者、数据分析学习者及Python初学者的完整项目资料,围绕2014至2017年图书借还数据展开主题词提取、热门书分析、排名变化与帕累托分析等典型任务,适合用于课程设计、毕业设计或实际业务场景的数据挖掘实践。资料包共16个文件,压缩后约46.45MB,内含7个xlsx原始数据表、4个py分析脚本、3个txt辅助语料及readme说明,文件类型划分清晰,便于按数据处理流程快速上手。目前已有2275人学习下载。读者可以借助现成脚本直接复用分析逻辑,结合读者信息、图书目录和借还记录完成从数据清洗到可视化展示的完整流程,背景图中还提供了可用于报告的掩码素材,具有较强的参考与扩展价值。

1. 从 Excel 流水到借阅画像:这个标题在解决什么

图书馆管理系统每天导出的借阅记录,通常是一张大而全的流水表:借书证号、书名、ISBN、借书日期、还书日期、续借次数,动辄几万行。馆员月底用它做统计,最常用的操作是把 Excel 透视表拖来拖去,得到“本月借了 X 册”这种总数。但领导追问“哪类书该加副本”“哪些读者是真活跃”时,透视表就很难回答了。基于 Python 的图书馆借阅数据分析设计与实现,要做的事就是把这张流水表变成可决策的信息:读者画像、分类热度、月度趋势、馆藏建议,全部用脚本跑出来。

这套方案适合两类人:一类是高校或公共图书馆里被报表缠住的馆员,想用 Python 替代手工透视;另一类是刚接触数据分析的开发者,需要一个字段清晰、业务含义明确、又不会涉及复杂隐私的练手项目。借阅数据的好处是没有坑人的缺失率,也没有复杂的时间序列,但它的脏数据问题很典型:编码混乱、日期格式不统一、学号丢前导零、还书日期早于借书日期。做过一遍,后面处理其他业务数据会有很强的迁移感。

2. 借阅数据的清洗与字段设计:先让数据能统计

2.1 图书管理系统能导出什么:字段清单与口径确认

常见做法是,图书管理系统支持按时间段导出借阅流水,大多能导成 Excel 或 CSV。拿到手的第一件事不是写代码,而是确认字段语义。我一般会把字段清单和口径先整理成一张表,发给熟悉系统的老师确认,这张表就是后面分析的“合同”。

字段名示例值口径说明分析用途
借书证号2023010112学生学号或读者证号,可能带前导零读者去重与画像
读者类型本科生 / 研究生 / 教师判断读者群体分层统计
所在学院计算机学院关联读者归属学院维度分析
书名Python编程:从入门到实践显示用,不适合直接聚合热门书排行
ISBN978-7-115-42846-6同一本书可能有多个版次书目去重
分类号TP311.56中图法分类号,决定学科热度分类占比
借书日期2024-03-01借出时间月度趋势
还书日期2024-03-15未还可能为空借阅周期
续借次数1续借只延长应还时间,不是新借活跃度修正

这个环节最容易忽略的是“续借次数”的口径。有的系统把续借算成一条新记录,有的系统只在原记录上累加次数。不确认清楚,后面算借阅量就会翻倍。另一个需要确认的是:已还记录和未还记录是否在同一张表里。未还记录的还书日期为空,计算借阅周期时必须排除。

2.2 没有真实数据时:用模拟数据把流程跑通

真实借阅记录涉及读者隐私,直接贴出来不合适。我习惯先用模拟数据生成一份结构相同的假数据,把清洗和分析流程跑通,再换成真实导出的 Excel 就能无缝使用,真实数据只在本机运行不外传。下面这个脚本生成一张流水表,包含一万多条借阅记录。

import pandas as pd import numpy as np from datetime import datetime, timedelta np.random.seed(42) n_reader, n_book = 200, 600 n_record = 12000 # 读者表:200个读者,含学院与读者类型 readers = pd.DataFrame({ "reader_id": [f"{20230000 + i:08d}" for i in range(n_reader)], # 学号补零到8位 "reader_type": np.random.choice(["本科生", "研究生", "教师"], n_reader, p=[0.7, 0.2, 0.1]), "dept": np.random.choice(["计算机", "机械", "经管", "外语", "法学"], n_reader), }) # 图书表:600本书,含分类号和上架日期 books = pd.DataFrame({ "book_id": range(n_book), "category": np.random.choice(["计算机", "文学", "历史", "经济", "艺术", "哲学"], n_book), "shelf_date": [datetime(2022, 1, 1) + timedelta(days=int(np.random.exponential(300))) for _ in range(n_book)], }) # 借阅流水:随机选读者和书 rows = [] for _ in range(n_record): rid = readers.sample(1).iloc[0] bid = books.sample(1).iloc[0] borrow = datetime(2022, 1, 1) + timedelta(days=int(np.random.randint(0, 1095))) # 20%概率未还,80%概率已还 return_date = borrow + timedelta(days=int(np.random.randint(3, 60))) if np.random.rand() > 0.2 else pd.NaT rows.append({ "reader_id": rid["reader_id"], "reader_type": rid["reader_type"], "dept": rid["dept"], "book_id": bid["book_id"], "category": bid["category"], "shelf_date": bid["shelf_date"], "borrow_date": borrow, "return_date": return_date, "renew_count": int(np.random.choice([0, 0, 1, 2])), # 大多数不续借 }) df = pd.DataFrame(rows) df.to_excel("lending_record.xlsx", index=False) df.to_csv("lending_record.csv", index=False, encoding="utf-8-sig")

这段代码生成了三张表:读者表、图书表、借阅流水表。生成的时候就把三个常见脏数据问题埋进去了:学号用20230000 + i格式化成了 8 位字符串,保证前导零不丢;return_date有 20% 的概率是空值,模拟未还记录;renew_count用np.random.choice控制大部分是 0,避免续借次数分布太极端。

参数说明:np.random.seed(42)让随机结果可复现;n_reader=200, n_book=600只是方便演示,真实场景根据系统导出规模调整;p=[0.7, 0.2, 0.1]控制读者类型比例,可以按学校实际情况修改。这里特意用timedelta(days=int(np.random.exponential(300)))生成上架日期,是为了让图书的“在架时长”有差异,后面算借阅密度时用得上。

2.3 清洗三板斧:类型修正、时间解析、去重

用pd.read_excel直接读取导出的流水,第一件事是把所有列都按字符串读进来,再做类型转换。这一步至关重要:Excel 文件里的借书证号、ISBN 经常被 Excel 自动转成科学计数法,直接读成 int 后精度会丢失。

import pandas as pd # dtype=str 防止学号、ISBN 被自动转成数字 df = pd.read_excel("lending_record.xlsx", dtype=str) # 统一日期格式:把 2024/1/1 这类斜杠格式转成短横线,再解析 for col in ["borrow_date", "return_date"]: df[col] = df[col].str.replace("/", "-", regex=False) df[col] = pd.to_datetime(df[col], errors="coerce") # 学号补零:有些系统导出后丢了前导零 df["reader_id"] = df["reader_id"].str.zfill(8) # 关键字段去重:同一人同一天借同一本书,视为重复流水 dup_before = len(df) df = df.drop_duplicates(subset=["reader_id", "book_id", "borrow_date"]) print(f"去重: {dup_before} -> {len(df)}") # 剔除还书日期早于借书日期的错误记录 err = df[df["return_date"] < df["borrow_date"]] print(f"异常记录: {len(err)}") df = df[~(df["return_date"] < df["borrow_date"])]

清洗逻辑有三处值得说明。第一,dtype=str是读取 Excel 最保险的做法,日期列后面单独用pd.to_datetime解析,避免 Excel 把日期序列化成数字后解析失败。第二,errors="coerce"让无法解析的时间变成NaT,后面用isna()统计缺失,而不是让程序直接报错中断。第三,去重按“读者+书+借书日”三个字段,因为正常业务里同一个人同一天不会重复借同一本书两遍,出现重复基本是系统重试或手动补录导致的。

参数说明:str.zfill(8)按 8 位补零,如果你的借书证号是 10 位就改成zfill(10);drop_duplicates的subset可以根据实际字段调整,比如要保留续借记录就把renew_count加进去。最后一步过滤错误记录时,我选择直接剔除而不是填充,因为还书日期早于借书日期属于录入错误,没有可靠修复依据,但要把剔除数量打印出来,方便后续发现系统长期存在录入问题时反馈给管理员。

2.4 清洗结果校验:不急着分析,先看数据基本面

清洗完不要急着画图,先用几行代码看基本面:总记录数、读者数、时间范围、未还比例。这些数字能暴露第一个版本清洗逻辑遗漏的问题。

print("流水总量:", len(df)) print("唯一读者:", df["reader_id"].nunique()) print("唯一图书:", df["book_id"].nunique()) print("时间范围:", df["borrow_date"].min(), "->", df["borrow_date"].max()) print("未还记录数:", df["return_date"].isna().sum()) # 按年看每月借阅量,确认趋势没有突变 monthly = df.groupby(df["borrow_date"].dt.to_period("M")).size() print(monthly.head())

这里nunique()是判断去重是否过度的关键指标。如果去重前唯一读者 200 人,去重后变成 150 人,说明drop_duplicates的subset选得太宽了,把合法借阅也删了。return_date.isna().sum()看未还记录占比,正常在 10%~30% 之间,如果超过 50%,要回去确认导出时是否只导出了未还记录。

to_period("M")是 pandas 里处理月度聚合很顺手的方法,它能把时间戳归一到月份周期,比手动提取year和month两列再groupby更直接。上面这段校验脚本不用保存,它更像是清洗逻辑的“冒烟测试”,每次改完清洗步骤都要跑一遍,确认数字在合理区间再进入下一章。

3. 用 Pandas 算借阅指标:月度趋势、分类热度与读者分层

3.1 指标设计:先定口径,再写代码

分析之前先想清楚要输出什么结论。借阅分析标题很大,真正能落到馆藏和服务的核心指标只有五个:借阅量趋势、分类热度、读者活跃度、借阅周期、借阅密度。每个指标的业务含义和计算口径要先定死在表格里,否则后面来回改代码很痛苦。

指标名计算口径业务含义
月度借阅量按 borrow_date 归月,统计记录数整体需求走势
分类借阅占比按 category 分类统计次数 / 总量馆藏配置是否合理
读者活跃度单人借阅次数,分 0 / 1-3 / 4-9 / 10+ 档核心读者群识别
借阅周期已还记录的 return_date - borrow_date判断借期设置是否合理
借阅密度借阅次数 / (统计截止日 - 上架日期) 月数新书热门度的公平比较

这里特别提一下借阅密度。直接用总借阅次数给图书排名,对 2024 年 1 月上架的新书不公平,它在架 2 个月被借 20 次,显然比 2022 年在架 24 个月被借 30 次更热门。借阅密度的口径是“单位在架时间内的被借次数”,这样新书和老书才能放在同一张榜上比较。

3.2 月度趋势与分类热度:一个 groupby 加一个 resample

月度趋势用上一章校验时见过的to_period("M")来聚合。分类热度则要看两种形态:一种是绝对值的分类排行,适合回答“哪类书借得最多”;另一种是分类占比随时间的变化,适合回答“某类书的热度是在上升还是下降”。

import pandas as pd # 按月统计借阅量 df["month"] = df["borrow_date"].dt.to_period("M") monthly_trend = df.groupby("month").size().reset_index(name="count") monthly_trend["month"] = monthly_trend["month"].astype(str) # 分类借阅绝对值 cat_rank = df.groupby("category").size().sort_values(ascending=False) print(cat_rank) # 分类占比随月度变化:行是月份,列是分类,单元格是借阅次数 cat_month = df.pivot_table(index="month", columns="category", values="book_id", aggfunc="count", fill_value=0) cat_month_pct = cat_month.div(cat_month.sum(axis=1), axis=0) # 转成占比

pivot_table生成的是透视表结构,行是月份、列是分类。第一次做的时候容易直接拿这个表去看绝对值,但月份之间的借阅总量本身在波动,直接比绝对值会误判。div(cat_month.sum(axis=1), axis=0)把每一行除以当月总量,转成占比后再看分类的变化趋势才靠谱。

参数说明:aggfunc="count"是对book_id计数,也可以换成aggfunc="sum"去统计某个数值列;fill_value=0把没有借阅的月份补成 0,避免透视表出现空值导致后续计算报错。分类字段如果用的是中图法分类号而不是中文分类名,建议先做一张分类映射表,把TP311.56映射成“计算机”,否则表和图都会很难读。

3.3 读者分层:活跃度不是简单数次数

读者活跃度分层,核心动作是按借书证号分组计算借阅次数,再分段。但这里有两个口径容易混:续借次数不能算作新的借阅;未还的记录也算一次有效借阅,因为它说明书已经离开馆藏了。

# 读者维度聚合 reader_stat = df.groupby("reader_id").agg( borrow_times=("book_id", "count"), renew_total=("renew_count", "sum"), distinct_cat=("category", "nunique"), last_borrow=("borrow_date", "max"), ).reset_index() # 科学计数法的学号还原成字符串并补零 reader_stat["reader_id"] = reader_stat["reader_id"].astype(str).str.zfill(8) # 活跃度分档 bins = [0, 1, 4, 10, float("inf")] labels = ["沉默", "低频", "活跃", "核心"] reader_stat["level"] = pd.cut(reader_stat["borrow_times"], bins=bins, labels=labels, right=True) print(reader_stat["level"].value_counts())

这里pd.cut的边界要注意:bins=[0, 1, 4, 10, inf]表示借 1 本算低频,4 本以下是活跃,10 本及以上是核心读者。right=True表示区间是左开右闭,即(1,4]落在“活跃”。这个分档阈值不是固定的,小馆可以改成 0/2/6/15,指标设计阶段就应该和馆员确认。

distinct_cat是“读者涉猎的学科数”,这个指标常被忽略,但很有用:一个核心读者如果只借同一类书,说明他是深度需求;如果一个普通读者借了四个不同分类,说明他有探索性阅读需求,荐购时可以重点参考。last_borrow(最后借书日期)配合当前日期可以算“沉睡读者”,这在读者激活工作里很实用。

3.4 借阅周期与借阅密度:剔除未还后的正确算法

借阅周期只能用已还记录计算,因为未还记录的还书日期是空的。直接用两列时间相减之前,必须先剔除异常记录——真实系统里还书日期早于借书日期的现象并不少见,多半是人工录入时选错日期。

returned = df[df["return_date"].notna()].copy() returned["loan_days"] = (returned["return_date"] - returned["borrow_date"]).dt.days # 去掉小于1天的异常,比如当天借当天还 returned = returned[returned["loan_days"] >= 1] print(returned["loan_days"].describe()) # 逾期判断:假设默认借期30天,逾期标记 returned["overdue"] = returned["loan_days"] > 30 print("逾期率:", round(returned["overdue"].mean(), 4)) # 借阅密度:按book_id聚合 shelf_stats = df.groupby("book_id").agg( borrow_times=("book_id", "count"), shelf_date=("shelf_date", "first"), ).reset_index() shelf_stats["shelf_months"] = ( pd.Timestamp("2024-12-31") - pd.to_datetime(shelf_stats["shelf_date"]) ).dt.days / 30 shelf_stats["density"] = shelf_stats["borrow_times"] / shelf_stats["shelf_months"].clip(lower=0.5) print(shelf_stats.sort_values("density", ascending=False).head(10))

loan_days用.dt.days把时间差转成整数天数,这是为了后面分箱和比较方便。逾期阈值 30 天是默认值,不同图书馆的借期不一样:本科生可能是 30 天,研究生 60 天,教师在 90 天以上。如果读者类型混在一起,需要先按读者类型分组,各组用自己的借期阈值。

借阅密度这里用了一个clip(lower=0.5),作用是把在架月数不足 0.5 个月的书的计算分母撑到 0.5,避免刚上架一两周的书密度值飙到几十倍,干扰排行榜。这个处理是经验值,如果你的数据上新书特别多,可以把下界改成 1。pd.Timestamp("2024-12-31")是统计截止日,实际操作中应该用真实运行日期的前一天,保证每月重跑时结果口径一致。

4. 可视化输出:把报表变成一张能拍板的图

4.1 中文字体与基础画布:先解决乱码再谈美观

借阅分析的图表比指标本身更复杂的地方在于中文。matplotlib 默认字体不包含中文字符,不加设置画出来的图全是方块。这一步是每台机器都要做的“环境准备”,我把三个必要的设置写在一个块里,每次新环境先跑它。

import matplotlib.pyplot as plt import matplotlib.font_manager as fm # 查找系统已安装的中文字体,找不到就设置成 sans-serif 防止直接报错 zh_font = None for f in fm.fontManager.ttflist: if "SimHei" in f.name or "Microsoft YaHei" in f.name or "Noto Sans CJK" in f.name: zh_font = f.name break plt.rcParams["font.sans-serif"] = [zh_font] if zh_font else ["sans-serif"] plt.rcParams["axes.unicode_minus"] = False # 解决负号显示成方块的问题 # 统一定义图尺寸,4:3 的宽高比更适合借阅趋势 fig, ax = plt.subplots(figsize=(10, 6))

这段设置的逻辑是:遍历已安装字体找常见中文字体,找到就用,找不到就把字体回退到默认。axes.unicode_minus必须设置为False,否则坐标轴上的负号会被渲染成方块。如果你在 Linux 服务器上跑脚本,没有 SimHei,可以执行系统包管理器安装 Noto Sans CJK,然后重新运行这段查找逻辑。

4.2 三张核心图:趋势折线、分类条形、读者金字塔

三张图对应三个决策场景:看整体走势、看结构、看人群。第一张图是月度折线,适合回答“借阅量在涨还是跌”;第二张是分类条形图,适合回答“哪类书最受欢迎”;第三张是读者活跃分档的水平条形图,适合回答“核心读者有多少”。

# 图1:月度借阅趋势 fig, ax = plt.subplots(figsize=(10, 6)) ax.plot(monthly_trend["month"], monthly_trend["count"], marker="o", linewidth=2) ax.set_title("月度借阅量趋势") ax.set_xlabel("月份") ax.set_ylabel("借阅次数") plt.xticks(rotation=45) plt.tight_layout() plt.savefig("output_monthly_trend.png", dpi=150) plt.close() # 图2:分类借阅Top fig, ax = plt.subplots(figsize=(10, 6)) cat_rank.head(8).plot(kind="barh", ax=ax, color="#4C72B0") ax.set_title("分类借阅量Top8") ax.set_xlabel("借阅次数") plt.tight_layout() plt.savefig("output_category_rank.png", dpi=150) plt.close() # 图3:核心读者占比 fig, ax = plt.subplots(figsize=(10, 5)) level_order = ["沉默", "低频", "活跃", "核心"] level_counts = reader_stat["level"].value_counts().reindex(level_order) ax.barh(level_order, level_counts.values, color=["#999999", "#5DA5DA", "#F4A261", "#E76F51"]) for i, v in enumerate(level_counts.values): ax.text(v + 5, i, str(v), va="center") ax.set_title("读者活跃度分层") ax.set_xlabel("人数") plt.tight_layout() plt.savefig("output_reader_level.png", dpi=150) plt.close()

三张图都用了plt.savefig(dpi=150)输出 PNG 而不是plt.show(),因为实际使用场景里,脚本通常是放在服务器或办公电脑上定时跑的,结果要打包进报告发给不同的人看,没有人守在屏幕前。

图2我用barh横向条形图,分类名是中文,横向排布比纵向更易读,不至于让“计算机科学与技术”这类长分类名倾斜折叠。color="#4C72B0"选的是色盲友好的配色,这个细节在正式报告中会有用。

图3从value_counts()拿到的结果进行reindex(level_order)排序,保证条形图从上到下是“沉默、低频、活跃、核心”而不是按借阅人数自动重排,否则图的叙事性会打折扣。ax.text(v + 5, i, str(v), va="center")在条形末端标注人数,这是我认为最省事且清晰的做法,不需要额外的图例。

4.3 把图表拼成一份可发的 HTML 报告

单张图发给别人,对方容易丢失上下文。更常见的做法是把图拼进一份带简单文字结论的 HTML 报告。不需要现成的报表框架,用一个基础模板就够了。

html = f"""<!DOCTYPE html> <html><head><meta charset="utf-8"> <title>月度借阅分析简报</title> <style> body {{ font-family: "Microsoft YaHei", sans-serif; margin: 40px auto; max-width: 900px; }} img {{ max-width: 100%; margin: 20px 0; }} h2 {{ border-left: 4px solid #4C72B0; padding-left: 12px; }} </style></head><body> <h2>一、月度借阅量趋势</h2> <p>{monthly_trend["count"].sum()} 条借阅记录,月均 {round(monthly_trend["count"].mean(), 1)} 次。</p> <img src="output_monthly_trend.png"> <h2>二、分类借阅结构</h2> <p>借阅量最高的是:{cat_rank.index[0]}。</p> <img src="output_category_rank.png"> <h2>三、读者活跃分层</h2> <p>核心读者 {level_counts["核心"]} 人,贡献了 {round(reader_stat[reader_stat["level"]=="核心"]["borrow_times"].sum() / len(df) * 100, 1)}% 的借阅量。</p> <img src="output_reader_level.png"> </body></html>""" with open("report.html", "w", encoding="utf-8") as f: f.write(html)

HTML 里的三行结论文字不是用来装饰的,它们是分析结果的核心交付物。馆领导看报告时通常只读第一段文字,图表是给想深入看的人准备的。所以每段结论必须是一个可量化的判断句,比如“核心读者贡献了 43.6% 的借阅量”,而不是“读者活跃度分布见下图”。

这个模板的扩展空间很大:想加学院排行,就复制一个h2区块替换图片和文字;想发邮件,用 Python 的smtplib把这个 HTML 作为正文发出即可。我通常最后还会把report.html改名加上日期,比如report_2024_W24.html,方便追溯每次跑了什么数据。

5. 避坑:借阅分析里我踩过的五个数据坑

5.1 时间字段混入斜杠格式与 Excel 序列号

现象:pd.to_datetime解析借书日期时,有一部分行报错变成NaT,检查原始 Excel 发现同一列里既有2024/1/1也有2024-01-01,还有几个显示为45292的数字。

原因:图书管理系统导出时按不同字段模板拼接了日期,斜杠和短横线混在一起;而个别日期在 Excel 里被识别成了真正的日期类型,导出后以序列号存储。errors="coerce"会把它们统一转成NaT,但不解决根本问题。

解决:读取时先强制dtype=str,把所有值都当文本读入;然后统一替换斜杠为短横线,再判断哪些值是全数字的序列号,用pd.to_datetime(1899-12-30) + pd.to_timedelta(int(x), unit="D")转回日期。我在清洗脚本里加了一步“格式归一化”,代码如下:

import pandas as pd from datetime import datetime, timedelta df = pd.read_excel("lending_record.xlsx", dtype=str) def parse_mixed_date(s): s = str(s).replace("/", "-") if s.isdigit(): # Excel序列号,基准日期是1899-12-30 return datetime(1899, 12, 30) + timedelta(days=int(s)) try: return pd.to_datetime(s) except Exception: return pd.NaT for col in ["borrow_date", "return_date"]: df[col] = df[col].apply(parse_mixed_date)

说明:Excel 序列号基准是 1899-12-30(含一个历史 bug 偏移),这个基准值不要自己凭记忆写,我用datetime(1899, 12, 30)是长期验证过的结果。解析时优先尝试常规格式,全数字再走序列号分支。

5.2 学号前导零丢失后groupby出现“假读者”

现象:读者统计里出现大量只借过一次的“新读者”,姓名列还都是空的;后来核查发现借书证号2023000123被 Excel 存成了202300123,少了一位零。groupby("reader_id")把这些错位学号当成两个不同的人,导致读者总数虚高。

原因:Excel 中超过 11 位的数字会自动转成科学计数法,但 8 位学号不会。真正的原因往往是图书管理系统导出的文本字段里学号本来就是字符串,如果中间经过一次 Excel 打开再另存,文本被转成了数字。2023000123这种以 0 结尾的数字,转成数字后末尾的 0 被丢弃。

解决:读取时dtype=str只能防未转换的情况,对已经丢失的 0 无能为力。更可靠的办法是和读者表做一次匹配:用reader_id.astype(str).str.zfill(8)统一补零后,再和读者主表merge,合并后匹配不上的再人工核查。我一般把补零逻辑写在清洗函数里而不是数据读取阶段,方便复用。

5.3 同一本书多个 ISBN 导致分类排行失真

现象:分类热度榜上,某本经典教材因为出了第 3 版、第 4 版、第 5 版,被拆成了三个条目,每个条目都在榜单 10 名开外,但把三个版次合并后实际是第一。

原因:图书管理系统里 ISBN 是唯一键,每换一次版次就生成一条新书目记录。分类号相同但书名相近,直接用book_id聚合无法识别“同一本书的不同版次”。

解决:用分类号 + 规范化书名做合并键。规范化的做法是把书名里的空格、版本号、“:”(副标题分隔符)去掉,再和分类号拼接。代码里我会在生成指标前先加一个“书目归一化”步骤:

import re df["title_norm"] = df["book_title"].apply( lambda x: re.sub(r"[:::\s()()第\d版]", "", str(x)) ) df["book_group"] = df["category"] + "_" + df["title_norm"]

说明:re.sub的正则里第\d版匹配“第3版”这样的版次后缀,替换成空字符串。这个规则要谨慎:如果一本书的书名本身就含“第X卷”或“第二版”这类有实际分类意义的字样,会影响归并效果。所以我通常先跑一版,打印出book_group重复数最多的前 20 条,人眼确认后再放进指标计算。

5.4 还书日期早于借书日期的脏记录

现象:计算借阅周期时,loan_days出现负数,甚至出现 -300 多天的极端值,把平均借阅周期拉到一个完全不可信的值。

原因:系统录入还书日期时默认带出了当前日期,如果补录的是几年前的借书记录,还书日期反而早于借书日期;也有读者在自助借还机上操作时日期没有同步导致错误。

解决:清洗阶段直接剔除并计数,同时在日报里保留剔除数量。不要尝试用“取绝对值”或“把借书日期改成还书日期”这种自以为聪明的修法,因为这样会掩盖系统问题。如果剔除比例超过总记录的 1%,就需要反馈给系统管理员检查自助借还机的日期同步配置了。

5.5 续借次数被当成新借阅

现象:某个月的借阅量突然暴涨,追踪发现是系统升级后把读者续借操作也写入了一条新的借阅流水,导致同一个人同一本书在同一借期内出现了两条记录。

原因:不同版本的系统对续借的存储方式不同:旧版在renew_count字段累加;新版直接插一条新流水并把原记录的还书日期置空。清洗去重步骤没有覆盖这种“同人同书同时段”的模式。

解决:去重时把时间段考虑进去。先按reader_id + book_id分组,再判断同一组内两条记录的借书日期差是否小于 30 天且存在重叠,把重叠的后一条标记为续借并删除。我把处理逻辑写成独立函数,仅在数据来源系统升级时启用,平时保持关闭状态以免误删真实借阅记录。

def merge_renew_duplicates(df, gap_days=30): df = df.sort_values(["reader_id", "book_id", "borrow_date"]) df["prev_return"] = df.groupby(["reader_id", "book_id"])["return_date"].shift() mask = (df["borrow_date"] - df["prev_return"]).dt.days <= gap_days return df[~mask.fillna(False)]

说明:shift()取同一组上一行的还书日期,如果当前借书日期距离上一行的还书日期在 30 天内,视为续借产生的重复记录。gap_days是可调参数,默认 30 天要结合贵馆的借期设置来定。这个方法也有误删风险,如果读者真在还书后 20 天又借了同一本书,会被误判。所以我要求这个函数运行时必须打印删除量,每次维护时人工确认。

6. 收尾成组件:让每个月的分析只需一条命令

6.1 把清洗、指标、图表封装成带参数的模块

前面的脚本都是平铺直叙的顺序代码,换一个月的数据、换一个学院,就要手动改多处变量。第六个月我就受够了,决定把整套逻辑封装成一个带参数的入口文件。这样每月 1 号跑数据时,只需改一个日期区间。

import argparse import pandas as pd import matplotlib.pyplot as plt def clean_data(df): df = df.copy() df["borrow_date"] = pd.to_datetime(df["borrow_date"], errors="coerce") df["return_date"] = pd.to_datetime(df["return_date"], errors="coerce") df["reader_id"] = df["reader_id"].astype(str).str.zfill(8) df = df.drop_duplicates(subset=["reader_id", "book_id", "borrow_date"]) return df[~(df["return_date"] < df["borrow_date"])] def compute_metrics(df, top_n=10): df["month"] = df["borrow_date"].dt.to_period("M") monthly = df.groupby("month").size().reset_index(name="count") cat = df.groupby("category").size().sort_values(ascending=False).head(top_n) reader = df.groupby("reader_id")["book_id"].count() return monthly, cat, reader def plot_outputs(monthly, cat, reader): # 具体绘图代码见第四章,这里省略重复部分 pass def main(): parser = argparse.ArgumentParser(description="借阅分析脚本") parser.add_argument("--file", required=True, help="导出的Excel文件路径") parser.add_argument("--start", default="2024-01-01", help="统计开始日期") parser.add_argument("--end", default="2024-12-31", help="统计结束日期") parser.add_argument("--topn", type=int, default=10, help="分类榜条数") parser.add_argument("--overdue-days", type=int, default=30, help="逾期天数阈值") args = parser.parse_args() df = pd.read_excel(args.file, dtype=str) df = df[(df["borrow_date"] >= args.start) & (df["borrow_date"] <= args.end)] df = clean_data(df) monthly, cat, reader = compute_metrics(df, args.topn) print("月度趋势:\n", monthly) print("分类Top:\n", cat) print("读者人均借阅:", round(reader.mean(), 2)) if __name__ == "__main__": main()

命令行的参数里,--file是必填参数,指向每月新导出的 Excel;--start和--end默认值覆盖一整年,但月度运行时可以传最近一个月;--topn控制分类榜条数,避免排行榜太长失去焦点;--overdue-days默认 30 天,每个馆可以按自己的借期规则覆盖。参数设计的原则是:必须改的参数用required=True,不常改的参数给合理默认值,同时保留覆盖能力。

封装之后,每月运行脚本的命令只有一条:

python analyze.py --file lending_2025_01.xlsx --start 2025-01-01 --end 2025-01-31

如果要在 Windows 的定时任务或 Linux 的 cron 里跑,还可以加一行输出重定向:python analyze.py ... > report_2025_01.txt,日志和数据分离。

6.2 验证输出靠“三个一”:一眼、一数、一对比

模块化以后,代码本身是干净的,但没人保证新月份的数据不出幺蛾子。我的习惯是每次跑完,用“三个一”来验证输出可信度,再把这个结果发出去。

# 自动对比相邻两个月的总量变化 import sys report = { "borrow_total": len(df), "active_readers": df["reader_id"].nunique(), "silent_rate": 1 - df["reader_id"].nunique() / reader_total, } # 手工记录上一期的总量 prev_total = 12800 # 上次跑完脚本记住的值 if abs(report["borrow_total"] - prev_total) / prev_total > 0.3: print("警告:借阅总量环比变化超过30%,检查数据导出是否有误")

不需要太复杂的验证机制,“和上一期数据对比变化率”是我这些年养成的习惯。变化率超过 30% 就停下来确认,而不是直接相信新数据。图书馆借阅量受寒暑假、考试周影响有周期性波动,所以我会把上一期选在去年同期而不是上个月,避免把暑假自然回落当成数据异常。

6.3 更进一步:从统计到推荐和馆藏优化

指标稳定跑通后,这个项目的天花板不在统计,而在两个进阶方向。第一个方向是读者推荐:按“读者所在学院 + 历史借阅分类占比”计算偏好向量,给读者推荐本学院同学借得多的书,比单纯按全馆榜单推荐精准得多。第二个方向是馆藏优化:把每年的借阅密度按分类汇总,密度长期偏低的分类可以压缩复本量,密度高但馆藏量少的分类是下一轮采购的重点。

# 按学院+分类看偏好,用于推荐候选 dept_pref = df.groupby(["dept", "category"]).size().reset_index(name="count") dept_pref["rank"] = dept_pref.groupby("dept")["count"].rank(method="dense", ascending=False) top_dept_books = dept_pref[dept_pref["rank"] <= 5]

我在这类项目上的习惯是:先把统计报表稳定跑三个月,让馆员确认数字可信,再动推荐和采购决策功能,否则两件事叠在一起,出了问题很难判断是数据问题还是推荐算法问题。希望这个从清洗到封装的完整链路能帮你把借阅数据真正用起来,每月的分析不再靠手动透视表。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进