☰
机器学习文本分类实战:打造本地笔记自动归档器
2026/10/17 12:05:33 网站建设 项目流程

这个系列写到第十篇,机器学习理论聊过不少,但真正让我觉得“值回票价”的,往往不是什么高深的模型,而是把算法塞进日常流程里的小工具。今天这篇,我打算记录一个刚完工的本地笔记自动归档器:它会在你按下保存的那一刻,根据笔记内容打上“工作”“灵感”“备忘”“读书”这类标签,省去手动整理文件夹的功夫。项目本身不复杂——分词、向量化、一个线性分类器,再加一个几行的文件监听脚本。但正是因为足够贴近日常,它踩过的坑比前九篇加起来都多。如果你也攒了一堆乱七八糟的文本文件,或者正想找一个适合自己的机器学习入门案例,这篇可以直接参考。

1. 项目需求与技术选型思路

1.1 为什么想给笔记做自动标签

每天产生大量笔记,最麻烦的不是写下来,而是整理。我自己的习惯是把所有想法、会议记录、读书摘抄全部塞进同一个 Markdown 文件夹,时间一长,文件名基本就是“未命名-副本-3”这种样子。想找一个月前写的东西,只能全文搜索关键词。手动归档确实能解决一部分问题,但每存一条笔记都得停下来想“这属于哪一类”,一旦积累超过上千条,整理本身就成了巨大的心理负担。

尝试过用关键词规则来归类,比如把包含“会议”“项目”“截止日期”的笔记放进工作类,但现实中的文本不会那么听话,很多工作笔记里也会提到做饭和跑步。真正的区分信号往往藏在上下文里,而不是某个孤立的词。就拿我自己的一条真实笔记来说:“下午三点和组里确认预算,之后去银行办卡。”如果按关键词规则,“银行”很容易让人联想到生活,可整条笔记的核心其实是“确认预算”,这显然属于工作。硬写规则的话,你要列多少个例外才能覆盖这种情况?与其这样,不如让模型自己从标注好的笔记里学习规律,把“预算”“确认”“组里”这些组合词作为工作类的证据。

所以我决定做一个基于机器学习的自动打标签工具,让模型根据整篇内容的语义信息判断最合适的标签。这个项目本质上是一个文本多分类任务,输入是一段本地笔记文本,输出是一个预定义的标签。难度不大,但好处非常直接:以后我只需要把笔记丢进一个文件夹,分类和归档都可以自动完成。

1.2 模型选型:朴素贝叶斯和逻辑回归的选择

对于这种场景,第一反应就是拿朴素贝叶斯来试。它简单、训练极快,在小样本文本分类上表现也说得过去。但实际跑了交叉验证之后,我放弃了它。原因倒不是精度不够,而是朴素贝叶斯默认特征之间相互独立,中文文本里词和词之间的搭配关系很常见,“机器学习”和“机器”“学习”同时出现时,模型会重复计算信息,导致一些类的概率被过分放大。虽然最终分类结果未必差,但后续我想用预测概率来做归档阈值,它的概率输出不太靠谱,经常出现 0.99 这种过于自信的结果,小样本下尤其明显。

换成逻辑回归之后,效果明显更好。可以把逻辑回归理解成一个给词语打分再求和的分类器:每个词对每个类别都有正或负的权重,一篇笔记属于哪个类别,就看所有词的加权得分加总后哪个类最高。它不像朴素贝叶斯那样要求特征独立,权重之间可以自动互相抵消和补充,所以面对“确认预算”这种组合词,它会学习到“预算”对工作类有正向贡献,而不是独立地把“预算”和“银行”各算一遍。在 500 条左右小样本上,它的训练时间只有一两秒,预测单条笔记的时间不到一毫秒,完全够用。

我做了一张选型对比表,方便阅读定位:

维度朴素贝叶斯逻辑回归
训练速度极快快
预测速度极快快
概率校准一般,容易极端较好,可调阈值
小样本表现尚可更好
可解释性可看特征概率可看权重排序
内存占用低中

最终选了逻辑回归,还有一个原因是可以配合class_weight="balanced"来处理类别不平衡。后面在样本不够均衡时,这个参数帮了大忙。至于为什么不直接上深度学习或预训练语言模型,原因很简单:数据量只有几百条,用复杂模型必然过拟合;而且本地部署一个大规模模型加重了环境依赖,对一个“自己用”的小工具来说性价比太低。

1.3 文本向量化:为什么选择 jieba + TF-IDF

中文和英文不一样,英文单词天然有空格分隔,中文必须先分词。我用了 jieba,因为它在日常项目里足够快,词典也全,对口语化的笔记文本识别得不错。分词之后,文本从“今天 开会 讨论 项目 进度”这样的形式,才能进入后续流程。如果你处理的是英文笔记,完全可以换成nltk或者spaCy,但本地中文场景下,jieba 够用且省事。

向量化选择 TF-IDF。CountVectorizer 只统计词频,“的”“了”“我”这种高频词会成为噪声;TF-IDF 会对这些常见词降权,对每个文档中更独特的词加权。用生活里的类比来说,TF-IDF 像一个“稀有度加权器”,在公司里听见“预算”这个词,比听见“我们”更容易让你判断这是工作内容。比如“预算”在一个只谈工作的笔记里出现两次,它的 IDF 权重就会很高,模型更容易捕捉这个信号。Word2Vec 之类的嵌入表示我也想过,但需要额外的预训练模型,还增加了本地部署体积,对一个小项目来说有点杀鸡用牛刀。

2. 数据准备与标注策略

2.1 收集原始笔记与初步整理

训练数据来源是我自己的笔记文件夹。为了方便处理,我先把这个文件夹里所有 Markdown 文件统一转成 CSV,每行包含文件名、正文文本和标签。我用了pandas的read_csv读入,采集时只保留每个文件的前 1000 字,因为一条笔记通常不会太长,太长的正文会让模型把注意力放到后面的无效内容上。文件读取要小心编码,我在 Mac 上生成的文本是 UTF-8,从 Windows 同步过来的部分文件带着 BOM,需要统一用utf-8-sig读取,否则第一行会出现一个不可见字符,特别坑。

初步整理时还发现一个高频问题:Markdown 文件里混着大量代码块、超链接和图片引用,这些内容对分类没有帮助,甚至会把模型带偏。我用一个正则表达式把![](...)、[文字](链接)和代码块中间的内容全部替换成空格。这一步不是可做可不做,直接影响后面的向量化质量。如果你不清理,模型可能会学到“http”“png”这类符号与某个标签的伪关联,一旦新笔记里没有这些符号,预测就会不稳定。

给一个清洗函数的核心片段:

import re def clean_text(raw): # 去掉图片和链接 text = re.sub(r"!\[.*?\]\(.*?\)", " ", raw) text = re.sub(r"\[.*?\]\(.*?\)", " ", text) # 去掉代码块 text = re.sub(r"```.*?```", " ", text, flags=re.S) # 去掉多余的换行和空白 text = re.sub(r"\s+", " ", text) return text.strip()

2.2 打标签的小技巧

数据量不大,我一开始觉得手动给几百条笔记打标签不是难事,真做起来才发现很容易前后标准不一致。比如一篇关于“读《深度学习》并做了摘抄”的笔记,既像“读书”,又像“工作”。所以第一步不是直接标注,而是先定义标签体系。我最终只保留四个互斥标签:工作、生活、灵感、备忘。自己有明确的分类边界,训练出来才不容易混淆。标签不能太多,我当时尝试过八个标签,结果“思考”和“灵感”的边界越来越模糊,连我自己都说不清,更别说让模型学了。

具体标注时,我用了两个技巧:一是从同一个文件夹里批量选中,因为当初写某些笔记时已经放在了对应目录下,目录名本身就是弱标签;二是把剩下没有目录线索的笔记随机打乱,一次性看 50 条,快速判断绝大多数内容属于哪一类。每类我给到 80~120 条,一共 400 条左右,对逻辑回归来说已经是一个能出效果的规模。

如果你连标签体系都不想定,也可以用无监督聚类先跑一遍,看模型自己聚出的簇是否大致对应你的几类意图。但日常项目要的是稳定可控,个人建议还是先有标签再训练,聚类只适合探索和早期数据洞察。

2.3 数据清洗与文本预处理

分词之前,还需要对中文文本做一点清洗。数字和英文一般保留,比如“GPT-4”“Python”“2024 年”这些信息有识别度;标点符号可以去掉,但句号、问号这类对情感判断有用,对主题分类帮助不大,我在主题分类任务里是直接删除的。停用词表我试过,效果反而变差,因为笔记文本里经常出现“我们需要考虑一下”这种口语化表达,“我们”“一下”去掉之后,就剩下“需要”“考虑”,语义就偏了。后来我只是去掉了“的、了、吗、呢”这类纯语气助词。

另一个重要的点是不要在清洗时过度压缩。有人喜欢把所有字连在一起或者只保留名词,我试过之后发现会丢失动词信息。“推进”“汇报”“安排”都是典型的工作类动词,保留它们对分类很关键。分词之后用空格连接,得到一个干净的文本列,这才交给 TF-IDF。如果你有固定的专业术语,比如“微服务架构”,最好在 jieba 的词典里把这个词加进去,否则会被切成“微服务”和“架构”,虽然不致命,但会让特征表达变弱。

2.4 标签一致性的简单校验

标注完 400 条之后,我并没有急着训练,而是先跑了一个简单校验:把标注结果按标签分组,随机抽取每组 10 条,自己重新看一遍,看有没有明显标错的。这一步很多人会跳过,但我觉得很有必要。因为我当时就把一条“周末去超市采购清单”标成了“工作”,原因是里面提到了“对接供应商”,但整篇内容明显是生活采购。重新校验之后,我修正了大概 20 条类似问题,占总样本 5%。如果你不做这个动作,模型会努力学着把“供应商”和“工作”绑定,等真正预测到一条生活采购笔记时就会出错。

如果时间允许,还可以再找一个人帮你复标,哪怕只是抽 50 条,计算一下标注一致性。一致性低于 0.7 就说明标签定义不够清楚,需要回去修改。不过个人项目里通常只有自己一个人用,我这个步骤就用“隔一天再看一遍”来代替了。间隔一天能避免短期记忆带来的惯性判断,更容易发现前后标准不一致。

3. 模型训练与调参

3.1 构建训练管道

我用 scikit-learn 的Pipeline把向量化和分类器串起来,这样在交叉验证时不会出现数据泄漏。具体做法是:先对原始文本分词,再定义TfidfVectorizer,最后是LogisticRegression。这样做的好处是预测时只需要把整条管道joblib.dump保存下来,不用单独保存分词结果。

核心代码是这样的:

import jieba import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline from sklearn.model_selection import cross_val_score def tokenize(text): return " ".join(jieba.lcut(text)) df = pd.read_csv("notes_labeled.csv") df["text_clean"] = df["text"].apply(clean_text) df["text_cut"] = df["text_clean"].apply(tokenize) pipeline = make_pipeline( TfidfVectorizer(ngram_range=(1, 2), max_features=5000, min_df=2, sublinear_tf=True), LogisticRegression(max_iter=1000, class_weight="balanced") ) scores = cross_val_score( pipeline, df["text_cut"], df["label"], cv=5, scoring="f1_macro" ) print("CV F1 macro:", scores.mean())

这段代码里有两个细节值得展开说。sublinear_tf=True把词频做了对数变换,避免一篇特别长的笔记因为一个词出现 10 次而获得过于离谱的权重。class_weight="balanced"表示按类别频率自动调整权重,后面在“灵感”类只有 70 条、“工作”类有 130 条时,这个参数能防止模型把所有内容都往工作类推。如果你只用准确率评估,这个操作可能看不出差别,但用 macro-F1 时就能明显感受到变化。

3.2 关键参数是怎么确定的

ngram_range=(1, 2)是我对比之后定下来的。只做单个词时,“机器学习”被切分成“机器”和“学习”,模型分不清这两个词是一起出现的还是各自出现的;加入二元词组后,“机器学习”成为一个特征,能把类似“机器 学习 算法”这种语境识别得更稳。再用三元甚至四元词组意义不大,因为笔记文本里很少有稳定的三词搭配,特征矩阵会变得非常稀疏。

max_features=5000是结合语料规模来定的。我总共只有 400 条笔记,每条平均几十到一百字,切出来的有效词总数不到一万。取前 5000 个词,既保留了绝大多数有效信号,也压缩了特征空间。min_df=2表示一个词至少要在两篇笔记里出现,否则很可能是在某篇文章里打错字,或者是不规范的自造缩写,留着只会增加噪声。我一开始没有设min_df,结果出现了一些只出现过一次的人名、地名,模型记下来后导致单个样本对结果的影响过大。

还有max_df,我没有使用固定值,因为去掉出现次数过高的词往往会把“工作”里的“项目”也去掉,而这种词其实是有类别区分度的。你可以用max_df=0.95做个测试,但我的结论是,如果语料本身就比较干净,TF-IDF 的固有降权已经能处理好常见词,没必要再做一刀切。调完参数后,我还在同一个管道上试过C值(正则化强度)。默认C=1.0在小样本上表现稳定,调到 0.5 反而过平滑,调到 2.0 则容易过拟合。这个项目里我没有做贝叶斯搜索,靠几次手动交叉验证就确定了最优值,因为数据规模太小,网格搜索的结果波动也很大。

3.3 交叉验证与评估指标

我用了 5 折交叉验证,因为 400 条数据分成 5 份,每份 80 条,训练集 320 条,测试集 80 条,这样的划分比较稳定。如果只用一次 train_test_split,运气不好会抽到某个标签特别集中的测试集,评估指标虚高或者偏低,参考价值不大。交叉验证的过程也会让我对模型的泛化能力有个更冷静的判断。

评估指标我选了f1_macro,也就是每个类别算 F1 再取算术平均。四个标签数量并不一致,如果选accuracy,模型只要把更多笔记判为“工作”,准确率也会很好看,但这个结果对整理笔记没用;选 micro-F1 同样会被大类数量带偏。Macro-F1 对每个类别一视同仁,少数类做得不好,分数立刻就会掉下来,这跟我的目标是一致的。实际跑下来,四类 macro-F1 在 0.82 左右,对一个小样本文本分类器来说已经很够用了。如果你发现自己类的数量差距特别大,还可以打印出每一类的 precision 和 recall,看具体是哪一类拉低了分数,而不是只看一个总分。

3.4 小心验证集和测试集混用

我在第一次调参时犯过一个新手错误:用同一份交叉验证结果反复调参,不停打开 CSV 看哪些样本被分错,然后根据这 400 条样本的特点去加规则、去停用词,最后发现验证集分数越来越高,但真正等新笔记进来后效果非常差。这就是典型的信息泄漏,不是数据上泄漏,而是人为地把验证集的信息引入了调参过程。正确的做法是把数据分成训练集和留出集,调参阶段只碰训练集和交叉验证,留出集最后评估一次。个人项目里因为数据少,留出 80 条会心疼,但这个损失是必要的。

后面我把数据重新切分,用 320 条训练,80 条留出,调参过程只在 320 条上做 5 折交叉验证,等所有参数都定了再在 80 条上跑一次。这个流程走下来,模型在留出集上的 macro-F1 是 0.80,与交叉验证分数基本一致,我心里就有底了。

4. 模型应用与落盘

4.1 训练完成后保存模型

交叉验证通过后,我在全量数据上重新训练了一次,然后直接用joblib.dump保存。这里有个经验:不要再保存一个训练时用的 DataFrame 对象,只需要保存管道本身。管道里已经内含 TF-IDF 词汇表和逻辑回归权重,下次加载后可以直接对原始文本做预测。文件体积也不大,我的模型只有 200KB 左右,放哪个目录都不占地方。

保存代码:

import joblib pipeline.fit(df["text_cut"], df["label"]) joblib.dump(pipeline, "models/notes_model.joblib")

注意保存路径不要带中文,有些部署环境对中文路径的处理很诡异。我会在项目根目录建一个models/目录,模型统一放进去;分词工具 jieba 虽然有自己的缓存,但模型文件本身不依赖 jieba 的缓存,所以拷贝到另一台机器上也能正常预测,只要那台机器安装了相同版本的 jieba 和 scikit-learn。如果你换版本后加载模型遇到报错,大概率是 pickle 版本兼容问题,重新在目标机器上用相同版本训练一次是最省事的解法。

4.2 做一个命令行快速打标签工具

模型落盘后,最直接的应用是命令行工具。我写了一个几十行的 Python 脚本,读取参数里的文本,输出预测标签和各类别概率。概率输出很重要,因为它能让我判断模型是不是在瞎猜。如果最高概率只有 0.4,我会把这条笔记放进“待人工整理”目录,而不是直接归档。这个思路类似于给模型加一个置信度门槛,宁可漏判也不误判。

核心部分:

import sys import joblib import jieba pipeline = joblib.load("models/notes_model.joblib") def predict_one(text): text_clean = clean_text(text) text_cut = " ".join(jieba.lcut(text_clean)) probs = pipeline.predict_proba([text_cut])[0] label = pipeline.classes_[probs.argmax()] return label, probs.max() if __name__ == "__main__": content = sys.argv[1] label, confidence = predict_one(content) print(f"预测标签: {label}, 置信度: {confidence:.2f}")

实际用下来,命令行版本适合把文本复制粘贴进去临时用。它还有个好处:在自动化工具出问题时,保留一条兜底的人工调用通道。命令行工具不要输出太花哨,只返回标签和置信度就够了,后面要接别的程序也方便。

4.3 改造为文件夹监控自动归档

光有命令行还不够,我又加了文件监听功能。用的是 watchdog 这个 Python 库,监听指定目录,每当有新的.md文件出现或已有文件被修改,就读取内容做预测,然后移动到对应标签文件夹。这一步最容易被忽视的是文件写入时机:编辑器保存文件不是一次原子操作,可能会触发多次事件,如果不做延迟处理,程序可能在文件只写了一半时就去读内容,最后得到一个残缺的预测文本。

我的做法是监听器收到事件后等 3 秒,再检查文件大小是否在持续变化,如果不再变化才执行预测。如果预测置信度低于阈值,文件不移动,而是复制一份到08_待整理目录,并在原文件旁边留下一个简单的标记文件,方便我回查。阈值我设置在 0.5,太低会误归档,太高会增加人工整理负担,0.5 对四分类场景比较均衡。

这段监控脚本不算长,核心就是FileSystemEventHandler里重写on_modified和on_created。我不建议在这个流程里加太复杂的重试逻辑,跑了两周之后发现,预测失败的大多是空文件、二进制文件或纯图片的 Markdown,遇到这类情况只要跳过就行。

from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler import time import os class NoteHandler(FileSystemEventHandler): def on_created(self, event): self._handle(event) def on_modified(self, event): self._handle(event) def _handle(self, event): if not event.is_directory and event.src_path.endswith(".md"): time.sleep(3) file_size = os.path.getsize(event.src_path) time.sleep(1) if os.path.getsize(event.src_path) != file_size: return # 文件仍在写入 label, conf = predict_one(open(event.src_path, encoding="utf-8-sig").read()) if conf >= 0.5: # 移动到对应标签目录 ...

4.4 实际运行一周的效果记录

脚本上线后我并没有全自动跑,前一周保持“半自动”模式:每次自动归档后都把结果写进一个日志文件,晚上抽几分钟看一下有没有明显误判。一周下来,一共归档了 43 条笔记,正确判断 36 条,误判 7 条,其中 4 条是置信度低于 0.5 但被强行归档的,另外 3 条属于标签边界本身模糊,我自己也拿不准该归哪一类。

这次实测给了一个很重要的修正方向:阈值需要分成类来设置,不能统一用 0.5。工作类样本多,0.5 足够可靠;灵感类样本少,预测概率普遍偏低,如果也按 0.5 卡,会漏掉很多真正想保留的灵感笔记。最终我把阈值改成每类单独设置,工作类 0.5,灵感类 0.4,生活类 0.5,备忘类 0.6。这种配置没法通过一次交叉验证得到,只能在真实使用中慢慢调。

5. 常见问题与排查技巧实录

5.1 中文乱码与编码问题

日常项目最常见的坎就是编码。我在 Mac 上读取到的 Markdown 文件默认是 UTF-8,但有一部分从 Windows 同步过来的文件带 BOM,用encoding="utf-8"读进来后,第一行开头会多出\ufeff。这个字符模型不认识,也不影响分类,但会在清洗后的文本里变成一个孤立“词”,严重时会挤掉一个真正有意义的词的位置。后来我统一用utf-8-sig读取,BOM 自动被剥离。还有 GBK 编码的旧文件,这类内容如果量很少,我直接跳过,不做转码,因为偶尔一个乱码样本对整体分类器没有帮助,反而增加判断成本。

另外提醒一下:在 Linux 服务器上运行脚本时,如果文件是由某人上传的 Windows 文本,换行符是\r\n,正则里\r可能混进分词结果。清洗时用text.replace("\r", " ")提前处理会比较省心。文件系统编码问题通常不会影响模型精度,但会扰乱你的数据读取过程,浪费大量时间。

5.2 预测速度慢怎么办

单条笔记的预测时间主要由分词决定,TF-IDF 向量化和逻辑回归几乎不耗时。jieba 首次加载词典需要一两秒,之后单条文本分词通常在几毫秒到几十毫秒。如果你需要批量预测几千条笔记,速度才值得优化。

我开始时直接把整篇 Markdown 全部丢进去分词,速度还行,但向量化之后产生了大量噪音词。后来改成只取标题和正文前 500 字,按字符计算,这样既保留了主题,又减少了无效内容。如果你有大量短文本要预测,另一个思路是并行分词,在多核机器上用concurrent.futures.ProcessPoolExecutor把数据分成几份,速度基本可以线性增长。但我这个场景没到那个量级,单条预测一天最多几百次,完全没有瓶颈。

5.3 样本不均衡与预测漂移

我的四类标签里,工作类最多,灵感类最少,一开始模型几乎把所有模棱两可的笔记都判成工作。打开预测结果看,发现“灵感”类的召回率只有 0.5,这就是典型的类别不平衡。我加了class_weight="balanced",问题解决了大半。如果你不想用这个参数,也可以试试随机欠采样,把小类复制几份,但这种方法在小样本上容易过拟合,我还是推荐参数方案。

还有一个更隐蔽的问题:预测漂移。随着时间推移,笔记里会出现新词汇,比如之前完全没有的“AIGC”“数字人”这类词,TF-IDF 词汇表里没有它们,向量化时会被直接忽略,模型只能靠周边的旧词猜,置信度会下降。所以模型不是训好就一劳永逸,我给自己定了个节奏:每两周或每新增 200 条笔记后,把新数据合并进来增量重训一次,这样标签体系才会跟上语言习惯的变化。重训时建议保留旧的训练数据,而不是直接用新数据,否则已经学会的旧词特征会慢慢遗忘。

6. 这个项目的后续演进方向

如果你把自己当成一个长期使用者,这个笔记归档器还有很多可以继续打磨的地方。我现在最想做的是把预测结果回写成笔记文件里已有的 YAML front matter,让原本只支持文件名分类的笔记软件也能直接读取标签。很多笔记软件支持tags:字段,在保存文件时自动把这个字段填充进去,检索体验会好很多。

另外,可以给每个标签维护一份“热点词表”,从逻辑回归权重中取出前 20 个正权重词,定期生成一份“我最近在关注什么”的词云。这种方式不算复杂,但每天看自己的笔记被模型提炼成几个关键词,会有一种意外的成就感。还可以把预测结果接入一个简单的日历提醒,当“备忘”类笔记里出现日期时,自动创建一个提醒事件,这已经超出文本分类的范畴,但有了可靠标签之后做后续加工就顺手很多。

我个人的体会是,这个项目并不需要更高深的模型,它的价值在于把一个数据管道的闭环打通:从原始文本、到标注、到训练、到部署、再到反馈重训。很多人卡在模型精度上反复调参,其实对一个日常工具来说,稳定能跑、结果可用、自己愿意复用,比任何指标都重要。最后再分享一个小技巧:如果你也打算做类似工具,阈值一定要从低到高慢慢调,先保证不误归,再追求召回,实际使用体验会好很多。如果你也想拿机器学习做点真正有实际产出的东西,不妨从自己的笔记开始动手,成本低,见效快。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询