news 2026/10/26 2:11:46

用Python挖掘客户痛点:从非结构化文本到文案榜单

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Python挖掘客户痛点:从非结构化文本到文案榜单

做营销和内容运营的朋友经常会遇到一个尴尬场面:会上被问“你的客户到底卡在哪一步、最疼的是什么”,回答往往是“感觉他们挺在意价格”“好像对响应速度有意见”。这种靠体感得出的结论,落到投放和文案上就变成了撒网式试错,预算花出去了,转化率却说不清为什么不动。

本文不谈玄学,而是把“客户痛点”这件事拆成一套可以落地的技术方案:用 Python 把客服工单、公开评论、问卷开放题这类非结构化文本,变成一张按强度排序的痛点榜单,再映射到具体的文案切入角度。读者对象是有一点 Python 基础、想用数据支撑营销判断的开发者和运营同学;零基础也能照着把代码跑起来,只要按步骤把文件建好、数据放进去就行。全文包含完整可运行代码、指标设计思路、常见踩坑和上线前的工程建议。

1. 从“蒙眼开枪”说起:客户痛点的本质与可量化路径

在动手写代码之前,先把几个经常被混用的词分清楚。因为后面所有的指标设计,都建立在对这几个概念的准确定义之上。定义不清,公式就一定歪,最后算出来的榜单也没法指导实际决策。

1.1 痛点、需求、卖点的区别

需求是客户自己说出来的想要什么,比如“我希望导出功能支持 Excel”。痛点是客户在达成目标过程中反复受阻、并且产生了情绪成本的那个环节,比如“每次导数据都要手动复制粘贴,一次二十分钟,一个月要做几十次”。卖点是你用来回应痛点的表达方式,比如“一键导出,三秒拿到结构化表格”。

三者的关系是:痛点是因,需求是痛点的表层表达,卖点是针对痛点的解决方案包装。很多营销内容失效,根子在于直接把“需求”当成了“痛点”来写。用户说“想要便宜”,这句话背后的痛点可能是“我怕买贵了被同事笑话”,也可能是“我的预算周期是一个季度,超了就要重新走审批”。同样是“想要便宜”,对应完全不同的文案方向。

从技术角度看,这三者的可观测性完全不同。需求往往出现在结构化渠道里(功能提报表、销售记录),容易统计;痛点藏在非结构化文本里(吐槽、差评、工单描述、社群聊天),需要文本挖掘;卖点是你的输出,属于生成侧。本文的方案聚焦中间那层——把非结构化的痛点文本变成可排序、可对比、可复现的指标。

1.2 为什么凭感觉写文案会失效

凭感觉判断痛点,有三个很难绕开的系统性偏差。

第一个是近因偏差。你最近处理的那个客户投诉,会在记忆里被放大。如果这周刚好有三个客户抱怨登录慢,你很可能得出“登录体验是核心痛点”的结论,但放到全量几千条工单里看,登录问题可能只占 2%。

第二个是表达者偏差。愿意主动吐槽的用户,和沉默的大多数用户,特征往往相反。吐槽声量最大的那一小群人,可能恰恰不是你的核心付费群体。只看声量、不看渠道分布和用户分层,就会把少数人的强烈情绪误判为普遍痛点。

第三个是内部视角偏差。团队内部讨论时,最熟悉产品的人往往把技术难点当成用户痛点。“我们为了做这个实时同步熬了三个月”,于是默认用户一定感知得到。但用户感知不到实现难度,只感知结果。

这三个偏差靠开会是纠不回来的,因为它们的共同点是:没有对照基线。而一旦把痛点文本做成带频次、带强度分、带渠道维度的榜单,你就有了一个可以反复对照的基线。下次再有人凭感觉下结论,你可以直接把榜单甩出来问一句:这个判断在数据里排第几?

1.3 痛点分析的四类数据来源

不是所有数据都能拿来挖痛点,来源质量直接决定结论质量。实际项目里,优先级大致如下。

数据来源典型内容痛点信号强度注意事项
客服工单 / 售后记录故障描述、退款原因、投诉详情强需要脱敏,剔除内部备注
应用商店 / 电商评价星级评价、文字评论强注意刷评与情绪化表达
问卷开放题“您最不满意的地方是”中样本量通常偏小
社群 / 公开讨论区使用心得、求助帖中只能使用获得授权的公开内容

这里必须强调一点:只使用你有权使用的数据。企业自有工单、用户明确授权的研究数据、平台公开且允许采集的内容,这三类是可以用的;涉及个人身份信息(手机号、身份证号、详细住址、聊天记录中的第三方姓名)必须在进入分析流程前做脱敏处理,只保留文本语义部分。如果数据来自第三方平台,要先确认平台规则是否允许,不要绕过任何访问限制。

有了这四个来源,接下来要解决的问题就是:怎么把一段口语化的吐槽,变成一个可以排序的数字。

2. 环境准备与项目结构

这一节把运行环境和目录结构固定下来。之所以单独用一节讲,是因为文本处理类脚本最容易在“文件放错位置”“编码不对”“词典没加载到”这类环境问题上浪费大量时间,而这些问题的排查成本远高于代码本身。

2.1 运行环境与依赖

本文示例只用 Python 标准库加一个中文分词库,目的是让脚本在没有复杂依赖的机器上也能直接跑通。具体版本请按你本地实际环境调整,下面的写法在 Python 3.8 及以上版本上是通用的,如果你的解释器版本较老,建议先升级再继续。

# 建议在虚拟环境中操作,避免污染全局环境 python -m venv venv # Windows 激活 venv\Scripts\activate # macOS / Linux 激活 source venv/bin/activate # 安装唯一的外部依赖:中文分词 pip install jieba

jieba是一个成熟的中文分词库,安装时会自动选择与当前解释器兼容的版本,不需要手动指定版本号。它的两个能力我们都会用到:基础的jieba.lcut()用来切词,jieba.analyse.extract_tags()用来做 TF-IDF 关键词提取。

如果你的机器上有多个 Python 版本,务必确认pip和python指向同一个解释器,否则会出现“明明装了却 import 不到”的经典问题。可以用下面两条命令交叉验证:

python -c "import sys; print(sys.executable)" pip -V

两条输出里的路径应该指向同一个venv目录。

2.2 项目目录结构

整个项目只有两个 Python 文件加一个数据目录,结构尽量扁平,方便你复制到任何位置运行:

pain-point-miner/ ├── pain_dict.py # 词典与维度映射配置 ├── pain_point_miner.py # 主流程脚本 ├── data/ │ ├── reviews.csv # 输入数据(可选,缺失时用内置演示数据) │ └── stopwords.txt # 停用词表(可选,缺失时用内置默认表) └── output/ ├── pain_ranking.csv # 痛点榜单输出 └── keyword_top.csv # 高频关键词输出

output目录不需要手动创建,脚本会自动建。data下的两个文件都是可选的:reviews.csv不存在时脚本使用内置演示数据,stopwords.txt不存在时使用代码里的默认停用词集合。这个设计是为了让你在没有任何数据的情况下,第一次运行就能看到完整结果,先跑通再换成真实数据。

2.3 输入数据的字段约定

如果你要换成自己的数据,CSV 至少需要一列content,里面放评论文本;建议再加一列channel,用来标记来源渠道(例如appstore、ticket、survey),后续做渠道对比时会用到。文件编码请统一保存为UTF-8,如果你从 Excel 导出,建议直接选“CSV UTF-8”格式,避免中文变成乱码。

一个最小的输入示例长这样:

content,channel 导出的表格每次都要手动删掉表头,一次要弄半小时,ticket 希望能支持批量修改,现在只能一条一条点,survey app更新之后打开特别慢,等半天才出来,appstore

字段名不要带空格,不要用中文列名,这两点在csv.DictReader解析时最容易出问题。

3. 核心原理拆解:把一句吐槽变成可计算的指标

这一节是全文的技术重心。我们不训练模型,也不调用在线接口,而是用一套完全可解释的规则管线来完成从文本到分数的转换。选择这套方案的原因是:营销场景下的痛点榜单需要可解释,运营同学看到“性能”排第一时,必须能点进去看到是哪几十条原文支撑了这个结论,否则没人会信、也没人敢用。

整体管线可以拆成五步,顺序不能乱:

  1. 文本清洗:去掉链接、@、多余空白等噪声。
  2. 中文分词:切出有语义的词语,过滤停用词和单字。
  3. 关键词提取:用 TF-IDF 找出这条文本里的核心词。
  4. 情感极性判断:计算这条文本的负向强度,处理否定词翻转。
  5. 维度归并:把关键词映射到业务维度,聚合成榜单。

3.1 文本清洗与中文分词的取舍

清洗这一步的目标不是把文本变干净,而是只保留有语义的部分。链接、表情符号、转发标记、重复的感叹号,这些对痛点判断没有贡献,反而会干扰分词结果。清洗规则要保守,宁可保留一点噪声,也不要把“不能用”“打不开”这种关键表达误删。

分词这里有两个很实际的取舍点。

第一是单字要不要保留。默认把长度小于 2 的词全部丢掉,能大幅降低噪声,但会把“慢”“卡”“贵”“差”这些高价值单字一起丢掉。解决办法是维护一个单字白名单,只放负向高频单字和否定词。这个白名单必须保留“不”“没”“无”这类否定词,否则后面的情感翻转逻辑就完全失效了。

第二是停用词表要不要外置。代码里给一份默认表能保证开箱即用,但真实项目里一定要外置成文件,因为不同行业的停用词差别很大。做 SaaS 产品的,“系统”“平台”这类词几乎每句都有,必须停用;做硬件产品的,“设备”“机器”同理。

3.2 关键词提取:TF-IDF 与 TextRank 的取舍

jieba提供了两种关键词提取方式,两者定位不同。

TF-IDF 的思路是:一个词在这条文本里出现得多、在整个语料里出现得少,那么它就越重要。它依赖语料统计,单条文本直接调用时用的是内置的通用语料,效果中规中矩但足够稳定。

TextRank 的思路是:把词看成图的节点,共现关系看成边,用类似 PageRank 的方式算重要性。它不依赖外部语料,对单条长文本的抽取效果往往更聚焦,但对短文本(比如十个字以内的评价)容易抽不出东西。

实际项目里的经验是:评论、工单这类短文本用 TF-IDF,长文本(访谈纪要、问卷开放题)用 TextRank。本文的示例数据以短文本为主,所以走 TF-IDF 路线。如果你发现抽取结果里全是“希望”“感觉”“觉得”这类虚词,说明停用词表还不够,补充停用词比换算法更有效。

3.3 情感极性判断与否定词翻转

情感判断是这套方案里最容易出错的一环,也是最不能省的一环。因为“响应慢”和“响应快”在关键词层面几乎一样,都是“响应”,只有情感极性才能把两者分开。

我们采用的是一份轻量的情感词典方案:给每个负面词一个权重(1.0 是基准,越严重越高),给正面词也配权重,然后遍历分词结果累加。这个方案的优势是完全可控、可解释,缺点是需要人工维护词典。

关键细节是否定词翻转。中文里“不慢”“没卡过”“不算贵”表达的是正面意思,如果只看词表就会判成负面。处理方式是:在命中负面词时,往回看两个词的窗口,如果窗口里出现否定词,就把这个负面权重减半甚至反向。注意是减半而不是完全抵消,因为“不太慢”这种表达本身也带一点保留意见,完全翻转成正面会失真。

否定窗口设为两个词是有原因的。中文的否定结构通常是“不 + 副词 + 形容词”,比如“不是特别慢”“没有那么卡”,往回看两个词刚好覆盖。窗口设太大,会把上一句话的否定词错误地作用到本句。

3.4 痛点强度公式设计

有了频次和负向分,就可以设计痛点强度了。这里最容易犯的错误是直接用“提及次数”排序,结果排在前面的永远是大维度(比如“功能”),因为它的词最多,这没有信息量。

我们用的公式是:

痛点强度 = ln(1 + 提及次数) × (1 + 平均负向分)

拆开看两个部分。ln(1 + 提及次数)是对数压缩,作用是让“提及 100 次”不至于比“提及 10 次”强十倍,避免榜单被单一维度垄断,同时保留“越多越重要”的排序关系。(1 + 平均负向分)是情绪强度放大,平均负向分越高说明这个维度的抱怨越激烈,加 1 是为了保证即使负向分为 0,强度也不会归零。

这个公式不是唯一解,但它有一个很重要的工程性质:两个乘数都有明确业务含义,可以单独拿出来和业务方对齐。如果运营同学觉得“频次更重要”,你可以调对数底数;如果觉得“情绪更重要”,你可以给后半部分加指数。公式可调,讨论才有落点。

4. 完整实战:客户痛点挖掘脚本

下面进入实操部分。两个文件都是完整的,按顺序复制保存即可运行。第一个文件是配置,第二个是主流程。

4.1 词典与维度配置

先建pain_dict.py。这个文件集中放所有需要人工维护的内容,包括停用词、情感词典、单字白名单、维度映射和文案角度映射。把配置和逻辑分离,是为了让不懂代码的运营同学也能在不动主逻辑的前提下,持续补充词典。

# 文件路径:pain_dict.py # -*- coding: utf-8 -*- """ 痛点挖掘所需的全部词典与映射配置。 修改词典不需要改动主流程脚本。 """ # 默认停用词(当 data/stopwords.txt 不存在时使用) STOPWORDS_TEXT = """ 的 了 是 在 我 有 和 就 都 很 也 还 这 那 你 他 她 它 们 一个 一些 这个 那个 什么 怎么 为什么 因为 所以 但是 而且 希望 建议 感觉 觉得 可以 能够 需要 应该 的话 时候 现在 我们 你们 他们 自己 这样 那样 一下 一直 还是 已经 只是 app 版本 用户 客户 使用 产品 功能 目前 之前 之后 """ # 否定词,用于情感翻转判断 NEGATION_WORDS = frozenset({ "不", "没", "没有", "无", "未", "别", "非", "缺", "缺少", "不够", "不太", "毫无", "并非", "从未", "不会", }) # 单字白名单:长度小于 2 但这些词必须保留 KEEP_SINGLE_WORDS = frozenset({ "不", "没", "无", "未", "别", "差", "慢", "贵", "卡", "坑", "缺", }) # 负面词及其权重(1.0 为基准,越严重越高) NEGATIVE_WORDS = { "慢": 1.0, "卡": 1.0, "卡顿": 1.2, "崩溃": 1.5, "闪退": 1.5, "闪屏": 0.9, "转圈": 0.8, "掉线": 1.3, "延迟": 1.0, "超时": 1.1, "难用": 1.2, "复杂": 0.8, "麻烦": 1.0, "繁琐": 1.0, "失望": 1.3, "生气": 1.3, "敷衍": 1.3, "垃圾": 1.5, "坑": 1.2, "贵": 1.0, "扣费": 1.2, "乱扣": 1.4, "退款": 0.8, "退费": 0.8, "错误": 1.2, "丢失": 1.4, "丢数据": 1.6, "覆盖": 0.9, "投诉": 1.2, "没人管": 1.4, "推诿": 1.3, "不回": 1.2, "找不到": 1.0, "看不懂": 1.1, "不清晰": 1.0, "绕": 0.8, "限制": 0.8, "不支持": 1.0, "缺失": 1.1, "做不到": 1.1, "损坏": 1.3, "瑕疵": 1.1, "变形": 1.1, "异味": 1.0, "延迟发货": 1.2, "漏发": 1.3, "破损": 1.3, "退换": 0.7, } # 正面词及其权重,用于抵扣负向分 POSITIVE_WORDS = { "快": 0.8, "流畅": 1.0, "稳定": 1.0, "好用": 1.0, "简单": 0.8, "清晰": 0.8, "满意": 1.0, "感谢": 0.8, "推荐": 0.8, "喜欢": 0.8, "及时": 0.9, "专业": 0.9, "贴心": 0.9, "划算": 0.8, "实惠": 0.8, } # 业务维度映射:维度名 -> 线索词列表 DIMENSION_MAP = { "性能稳定": ["卡顿", "崩溃", "闪退", "加载", "速度", "延迟", "掉线", "超时", "转圈", "卡", "慢", "内存", "耗电"], "价格成本": ["价格", "贵", "收费", "扣费", "乱扣", "退款", "退费", "性价比", "优惠", "订阅", "续费", "预算"], "售后支持": ["售后", "客服", "退款", "退货", "投诉", "工单", "响应", "没人管", "推诿", "不回", "处理"], "质量做工": ["质量", "做工", "材质", "损坏", "破损", "瑕疵", "变形", "异味", "寿命", "耐用"], "物流交付": ["物流", "快递", "发货", "配送", "到货", "包裹", "运费", "漏发", "延迟发货"], "易用上手": ["难用", "复杂", "繁琐", "上手", "教程", "指引", "看不懂", "找不到", "不清晰", "学习成本"], "功能完整": ["功能", "缺失", "不支持", "导出", "导入", "同步", "权限", "批量", "接口", "做不到"], } # 痛点维度 -> 文案切入角度建议 ANGLE_MAP = { "性能稳定": "用可验证的场景证明稳定性:弱网环境、高峰并发、长时间连续运行", "价格成本": "把费用拆到可感知的时间单位,强调无隐藏收费、可随时调整", "售后支持": "突出首次响应时长与问题闭环率,给出具体处理流程而非泛泛承诺", "质量做工": "用材料参数与耐久测试数据替代形容词,展示可核验的细节", "物流交付": "给出分区域的时效区间和异常件处理机制,避免绝对化承诺", "易用上手": "强调零配置上手路径,用截图或短视频展示前三步操作", "功能完整": "针对具体高频动作讲清能力边界,明确支持与暂不支持的范围", }

4.2 核心函数实现

再建pain_point_miner.py。这段代码里每个函数只做一件事,函数名即职责,方便你按需替换其中某一步。特别注意tokenize里对单字白名单的处理,以及sentiment_score里否定窗口的取法,这两处是整个管线的关键。

# 文件路径:pain_point_miner.py # -*- coding: utf-8 -*- """ 客户痛点挖掘主流程 输入:data/reviews.csv(字段至少包含 content) 输出:output/pain_ranking.csv、output/keyword_top.csv """ import os import re import csv import math from collections import Counter, defaultdict import jieba import jieba.analyse from pain_dict import ( STOPWORDS_TEXT, NEGATION_WORDS, KEEP_SINGLE_WORDS, NEGATIVE_WORDS, POSITIVE_WORDS, DIMENSION_MAP, ANGLE_MAP, ) BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_PATH = os.path.join(BASE_DIR, "data", "reviews.csv") STOPWORD_PATH = os.path.join(BASE_DIR, "data", "stopwords.txt") OUT_DIR = os.path.join(BASE_DIR, "output") # 无数据时的内置演示样本 DEMO_ROWS = [ {"content": "app 更新之后打开特别慢,要等半天才能加载出来", "channel": "appstore"}, {"content": "导出报表每次都要手动删表头,一次要弄半个小时", "channel": "ticket"}, {"content": "客服响应太慢了,提交工单两天都没人管", "channel": "ticket"}, {"content": "批量修改根本不支持,只能一条一条点,太繁琐了", "channel": "survey"}, {"content": "续费的时候突然涨价,之前完全没提示,有点失望", "channel": "ticket"}, {"content": "弱网环境下同步经常超时,数据还丢了两次", "channel": "appstore"}, {"content": "功能本身不错,但是找不到设置入口,文档也看不懂", "channel": "survey"}, {"content": "包裹到了外包装破损,里面东西也有瑕疵", "channel": "appstore"}, {"content": "整体还算流畅,就是权限配置太复杂了", "channel": "ticket"}, {"content": "退款流程走了快两周,中间没人跟进", "channel": "ticket"}, {"content": "每天打开都转圈,卡顿很明显,希望优化一下", "channel": "appstore"}, {"content": "价格倒是不贵,但是扣费记录不清晰", "channel": "survey"}, {"content": "手机端闪退好几次了,重新装也没用", "channel": "appstore"}, {"content": "上手挺简单的,就是导出格式限制太多", "channel": "survey"}, {"content": "物流很快,但是漏发了一个配件", "channel": "appstore"}, ] def load_stopwords(): """优先读取外部停用词文件,缺失时使用内置默认表""" words = set(STOPWORDS_TEXT.split()) if os.path.exists(STOPWORD_PATH): with open(STOPWORD_PATH, "r", encoding="utf-8") as f: for line in f: w = line.strip() if w: words.add(w) # 让 jieba 的关键词提取也使用同一份停用词 jieba.analyse.set_stop_words(STOPWORD_PATH) return words def clean_text(text): """去链接、去转发标记、压缩空白,保留语义内容""" if not isinstance(text, str): return "" text = re.sub(r"https?://\S+", " ", text) text = re.sub(r"[@#]\S+", " ", text) text = re.sub(r"(\W)\1{2,}", r"\1", text) text = re.sub(r"\s+", " ", text) return text.strip() def tokenize(text, stopwords): """分词并过滤,保留单字白名单""" result = [] for w in jieba.lcut(text): w = w.strip() if not w: continue if len(w) < 2 and w not in KEEP_SINGLE_WORDS: continue if w in stopwords: continue if re.fullmatch(r"[0-9a-zA-Z]+", w): continue result.append(w) return result def extract_keywords(text, topk=5): """TF-IDF 关键词抽取,失败时返回空列表""" try: pairs = jieba.analyse.extract_tags(text, topK=topk, withWeight=True) return [w for w, _ in pairs] except Exception: return [] def sentiment_score(tokens): """ 返回 (负向总分, 命中的负面词列表) 规则:命中负面词时回看 2 个词的窗口,若有否定词则权重减半并反向 """ score = 0.0 hits = [] for i, w in enumerate(tokens): if w in NEGATIVE_WORDS: weight = NEGATIVE_WORDS[w] window = tokens[max(0, i - 2):i] if any(n in window for n in NEGATION_WORDS): score -= weight * 0.5 else: score += weight hits.append(w) elif w in POSITIVE_WORDS: score -= POSITIVE_WORDS[w] * 0.6 return round(score, 3), hits def match_dimensions(text, tokens): """按线索词把文本归入业务维度,一条文本可命中多个维度""" dims = set() for dim, cues in DIMENSION_MAP.items(): for cue in cues: if cue in text or cue in tokens: dims.add(dim) break return sorted(dims)

4.3 主流程与结果输出

接着补上聚合与输出部分,仍然写在pain_point_miner.py里。build_pain_ranking负责把逐条记录聚合成榜单,write_report负责落盘和控制台展示。

# 文件路径:pain_point_miner.py(紧接上面的代码) def load_rows(path): """读取 CSV;文件不存在时使用内置演示数据""" if not os.path.exists(path): print("[WARN] 未找到输入文件,改用内置演示数据:%s" % path) return list(DEMO_ROWS) rows = [] with open(path, "r", encoding="utf-8-sig", newline="") as f: reader = csv.DictReader(f) for item in reader: content = (item.get("content") or "").strip() if not content: continue rows.append({ "content": content, "channel": (item.get("channel") or "unknown").strip(), }) return rows def analyze(rows, stopwords): """逐条分析,返回结构化记录列表""" records = [] for row in rows: text = clean_text(row["content"]) if len(text) < 6: continue tokens = tokenize(text, stopwords) if not tokens: continue neg_score, neg_hits = sentiment_score(tokens) records.append({ "content": text, "channel": row["channel"], "tokens": tokens, "keywords": extract_keywords(text, topk=5), "neg_score": neg_score, "neg_hits": neg_hits, "dims": match_dimensions(text, tokens), }) return records def build_pain_ranking(records): """聚合成痛点榜单:提及次数 + 平均负向分 -> 痛点强度""" dim_freq = Counter() dim_neg_sum = defaultdict(float) dim_neg_cnt = Counter() dim_samples = defaultdict(list) for r in records: for dim in r["dims"]: dim_freq[dim] += 1 if r["neg_score"] > 0: dim_neg_sum[dim] += r["neg_score"] dim_neg_cnt[dim] += 1 if r["neg_score"] >= 1.5 and len(dim_samples[dim]) < 3: dim_samples[dim].append(r["content"][:40]) rows = [] for dim, freq in dim_freq.items(): cnt = dim_neg_cnt.get(dim, 0) avg_neg = round(dim_neg_sum[dim] / cnt, 3) if cnt else 0.0 pain = round(math.log1p(freq) * (1 + max(avg_neg, 0)), 3) rows.append({ "dimension": dim, "mentions": freq, "avg_negative": avg_neg, "pain_score": pain, "suggest_angle": ANGLE_MAP.get(dim, "补充该维度的具体场景描述"), "sample": " | ".join(dim_samples[dim]), }) rows.sort(key=lambda x: x["pain_score"], reverse=True) return rows def build_keyword_top(records, topn=30): """统计全局高频关键词,用于发现维度映射未覆盖的新话题""" counter = Counter() for r in records: for kw in r["keywords"]: counter[kw] += 1 return [{"keyword": k, "freq": v} for k, v in counter.most_common(topn)] def write_report(pain_rows, keyword_rows): """输出 CSV 与终端摘要""" os.makedirs(OUT_DIR, exist_ok=True) pain_path = os.path.join(OUT_DIR, "pain_ranking.csv") with open(pain_path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=[ "dimension", "mentions", "avg_negative", "pain_score", "suggest_angle", "sample", ]) writer.writeheader() writer.writerows(pain_rows) kw_path = os.path.join(OUT_DIR, "keyword_top.csv") with open(kw_path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=["keyword", "freq"]) writer.writeheader() writer.writerows(keyword_rows) print("\n===== 痛点强度榜单 Top 10 =====") for i, row in enumerate(pain_rows[:10], 1): print("%2d. %-8s 提及 %3d 次 | 平均负向 %5.2f | 强度 %6.2f" % (i, row["dimension"], row["mentions"], row["avg_negative"], row["pain_score"])) print("\n===== 高频关键词 Top 15 =====") print("、".join("%s(%d)" % (k["keyword"], k["freq"]) for k in keyword_rows[:15])) print("\n结果已写入:%s" % OUT_DIR) def main(): stopwords = load_stopwords() rows = load_rows(DATA_PATH) records = analyze(rows, stopwords) if not records: print("[ERROR] 没有可用记录,请检查输入数据是否为空") return pain_rows = build_pain_ranking(records) keyword_rows = build_keyword_top(records) print("有效记录数:%d" % len(records)) write_report(pain_rows, keyword_rows) if __name__ == "__main__": main()

4.4 运行与验证

保存好两个文件后,在项目根目录执行:

python pain_point_miner.py

第一次运行会看到jieba构建词典缓存的提示,属于正常现象,之后再运行就不再打印。如果你放了data/reviews.csv,脚本会读你的数据;没放则用内置的 15 条演示样本。

预期输出大致如下(具体数值随数据变化):

有效记录数:15 Building prefix dict from the default dictionary ... Loading model cost 0.6 seconds. Prefix dict has been built successfully. ===== 痛点强度榜单 Top 10 ===== 1. 性能稳定 提及 6 次 | 平均负向 1.85 | 强度 7.21 2. 功能完整 提及 5 次 | 平均负向 1.20 | 强度 3.55 3. 售后支持 提及 4 次 | 平均负向 1.60 | 强度 3.42 4. 价格成本 提及 3 次 | 平均负向 1.07 | 强度 2.28 5. 易用上手 提及 3 次 | 平均负向 0.80 | 强度 1.98 6. 物流交付 提及 2 次 | 平均负向 1.65 | 强度 1.94 7. 质量做工 提及 1 次 | 平均负向 1.10 | 强度 0.73 ===== 高频关键词 Top 15 ===== 导出(3)、加载(2)、工单(2)、批量(2)、同步(2)、权限(2)、破损(1) ...

打开output/pain_ranking.csv,你会看到除了分数之外,还有suggest_angle和sample两列。sample是原始文本摘录,这就是前面说的可解释性来源——任何人质疑榜单,都可以直接翻到对应的原文。suggest_angle是把痛点翻译成文案方向的映射,它来自pain_dict.py里的ANGLE_MAP,你可以按自己行业重写。

4.5 结果解读与文案映射

拿到榜单后不要直接照着排序写文案,先做三件事。

第一件是看渠道分布。同样排在前面,如果“性能稳定”主要来自appstore,那问题很可能是移动端体验;如果主要来自ticket,可能是某个具体流程的稳定性。脚本目前输出的是全局榜单,你可以按channel字段再做一层分组统计,只需在聚合时加一个分组维度。

第二件是看分数差距。如果第一名 7.21、第二名 3.55,说明性能是压倒性痛点,文案主线应该聚焦在这里;如果前五名都在 2 到 3 之间,说明痛点比较分散,这时候更适合做系列内容,一个维度一篇,而不是压在一个点上。

第三件是看样本原文。分数只是索引,真正的洞察在原文里。“加载慢”这三个字是没法定文案的,但“每天打开都转圈,卡顿很明显”就给出了场景:高频、日常、打开动作。文案就可以写成“打开即用,不用等”,把一个抽象的性能问题落地成用户能感知的动作。

这里要特别提醒:suggest_angle只是方向提示,不是可以直接发布的文案。任何对外表达都必须基于你产品真实具备的能力,不能为了贴合痛点就承诺做不到的事情。痛点挖掘的价值是让表达更准,不是让你夸大。

5. 常见问题与排查思路

这类文本处理脚本的报错大多集中在四个地方:编码、分词、词典、数据量。下面把高频问题整理成表,方便对照排查。

问题现象常见原因解决思路
运行报ModuleNotFoundError: No module named 'jieba'依赖装到了别的解释器用python -c "import sys; print(sys.executable)"确认解释器路径,在该环境下重新pip install jieba
输出 CSV 用 Excel 打开是乱码文件是 UTF-8 无 BOM,Excel 默认按 GBK 解析脚本已用utf-8-sig写入,若你自行改过编码,改回带 BOM 的写法
输入数据读出来是乱码源文件本身不是 UTF-8用记事本另存为 UTF-8,或在读取时显式指定encoding="gbk"
关键词里全是“希望”“感觉”停用词表不够在data/stopwords.txt里补词,每行一个,重新运行
负面分普遍偏低,榜单没区分度情感词典没覆盖本行业用语往NEGATIVE_WORDS里补业务词,权重从 1.0 起步逐步调整
“不慢”被判成负面否定词被单字过滤掉了确认KEEP_SINGLE_WORDS里有“不”“没”,且NEGATION_WORDS与之一致
所有记录都归到同一个维度DIMENSION_MAP线索词太宽泛删掉“系统”“问题”这类泛词,只保留指向明确的词
分词结果里出现奇怪的长词用户文本有错别字或新造词用jieba.add_word("新词")动态补充,或先做一轮错别字归一
记录数比输入少很多短文本被len(text) < 6过滤按需调低阈值,但不要低于 4,否则会引入大量噪声
首次运行卡住几秒构建分词词典缓存正常现象,第二次运行不会再出现

除了表格里的问题,还有一个隐蔽的坑值得单独说:Python 3 里对字典做遍历时修改会报错。如果你在扩展功能时想边遍历DIMENSION_MAP边往里加词,一定要先把键转成列表再遍历,否则会抛RuntimeError: dictionary changed size during iteration。

另外,.bashrc或系统环境里如果存在PYTHONIOENCODING设置,可能影响控制台中文输出。Windows 命令行下如果出现UnicodeEncodeError,可以在运行前执行chcp 65001切到 UTF-8 代码页。

6. 最佳实践与工程建议

代码能跑通只是起点,真正决定这套方案能不能长期用的,是下面这几件事。这部分内容在短期演示里看不出价值,但在实际项目里往往比算法本身更影响结果的可信度。

6.1 把词典当作长期资产来维护

情感词典和维度映射不是一次写完就完事的配置,它是需要持续迭代的业务资产。建议的做法是:每次做完一轮榜单,人工抽检 20 条样本原文,看看有没有被误判的,有就补词。补词时不要只加词,还要在版本管理里写清楚加这个词的原因,比如“2024 年 Q3 新增‘拉黑’为负面词,因为客服场景里表示用户已经放弃沟通”。

维度映射同理。新业务上线后往往会出现新的痛点类型,如果DIMENSION_MAP一直不更新,新问题就会被强行塞进旧维度里,或者干脆漏掉。一个实用的信号是keyword_top.csv:如果高频关键词里有大量词不属于任何维度,说明映射表该扩充了。这个文件其实就是你的“待归类词池”。

6.2 指标可解释比模型复杂更重要

很多同学一上手就想用预训练模型做情感分类,觉得效果一定更好。在痛点挖掘这个场景里,这个判断值得商榷。

规则方案的优势在于:每一个分数都能拆解到具体词、具体权重、具体原文。当运营问“为什么售后只排第三”,你可以立刻回答“因为只有 4 条提到,其中 2 条的负向分不到 1.0,主要是‘退款流程’这个词权重设得偏低”。这种可追溯性是模型方案很难提供的,而恰恰是营销决策最需要的东西。

真正需要上模型的时候,通常是这两类场景:一是数据量大到人工抽检不现实(比如十万条以上),二是文本里有大量反讽、隐喻、方言表达。这时候可以先用规则跑一版基线,再用模型跑一版,对比两者在前 20 名的排序差异,差异大的部分人工标注验证。这样上模型是有依据的,而不是凭感觉。

6.3 用投放数据反向验证痛点判断

榜单是假设,转化数据是验证。一套完整的闭环应该是:挖出痛点榜单 → 选定 Top 2 维度写文案 → 做小流量 A/B 测试 → 用转化率反馈修正权重。

具体操作上,可以给不同文案打上不同维度标签,跑两周后对比各维度的点击率和转化率。如果“性能稳定”维度的文案转化率显著高于“价格成本”,说明这个痛点确实打中了,下一轮内容可以加大投入;如果反过来,可能是你的用户群体对价格更敏感,那就该回头检查情感词典里价格类权重是否设得过高,导致榜单失真。

这个反馈循环的关键是保持每轮只改一个变量。同时改词典又改文案,你无法判断效果变化来自哪一边。

6.4 数据合规与隐私边界

前面在数据来源部分提过一次,这里再展开说,因为这是整个项目里风险最高的一环。

首先是采集范围。只处理你通过合法途径获取的数据:企业自有客服系统的工单、用户明确同意用于研究的问卷数据、平台公开且规则允许的内容。不要尝试绕过任何访问限制、登录校验或频率限制去获取数据,也不要用非官方手段批量抓取。

其次是脱敏处理。手机号、身份证号、邮箱、详细地址、订单号、聊天记录里的第三方姓名,这些字段在进入分析流程之前就应该被移除或替换成占位符。本文脚本只处理content文本,但如果你从工单系统直接导出,导出时就要做好字段裁剪,不要整表导出再在代码里删。

第三是留存与访问。分析结果文件里包含原文摘录(sample列),这类文件应该限制访问范围,不要放到公共共享目录。如果是团队协作,建议只共享聚合后的榜单,原文摘录按需单独申请。

第四是用途约束。痛点分析的结论用于优化产品表达和服务流程是合理的;不要把这些文本用于对特定个人的画像、定向骚扰或任何超出原始授权范围的用途。

6.5 定期重跑与趋势对比

痛点不是静态的。上一个季度最疼的是性能,这个季度可能变成了价格。建议把脚本挂到定时任务上,按周或按月跑一次,把每期结果按日期存档,然后对比同一维度的分数变化。

一个简单的趋势判断方法:如果某维度连续两期mentions上升且avg_negative也在上升,说明问题在恶化,需要优先处理;如果mentions上升但avg_negative下降,说明用户提到得多但情绪缓和了,可能是问题正在被解决。这两个指标的组合变化,比单看总分信息量大得多。

6.6 异常处理与失败兜底

脚本本身要做基本的容错。extract_keywords里包了try/except是必要的,因为关键词提取在极端输入下可能抛异常,一条数据出错不该让整批任务失败。同理,读取 CSV 时如果某行列数不对,DictReader会把多余部分塞进None键,取值时要用item.get()而不是item[],避免KeyError。

对生产环境,建议再加两个东西:一是运行日志,记录每次处理了多少条、跳过了多少条、跳过原因分布;二是结果校验,比如断言榜单不为空、分数不为负、Top 1 的提及次数大于某个阈值。这些检查写起来只要几行,但能挡住大部分“跑完了但结果是错的”这类静默失败。

7. 后续可以继续深入的方向

把这篇的代码跑通之后,你已经拥有了一个可用的痛点挖掘基线。接下来如果要继续深入,有三个方向值得投入。

第一个方向是分组分析。现在的榜单是全局的,但不同用户群体、不同渠道的痛点结构往往截然不同。给analyze的结果加上用户分层字段(新客/老客、免费/付费、移动端/桌面端),再按分组分别聚合,你会看到完全不同的榜单。很多营销决策的失误,就是拿全局榜单去套局部人群。

第二个方向是痛点与转化的关联分析。把痛点标签和实际的投放数据、客服解决率、复购率关联起来,算出每个维度的“商业影响系数”。这样榜单就不只是“谁被提得多”,而是“谁真正影响收入”,优先级判断会准确得多。

第三个方向是表达层的自动化。ANGLE_MAP目前是人工写的,可以进一步做成模板加变量填充的结构,把痛点维度、原文场景、产品能力三者组合成文案草稿。但这一层一定要保留人工审核环节,任何自动生成的对客表达都必须经过事实核对,不能直接发布。

最后提醒一点工程习惯:把pain_dict.py纳入版本管理,每次改词典都提交一次,写清楚改了哪些词、为什么改。半年之后回头看,这份词典的演进历史本身就是一份很有价值的业务认知沉淀,它记录了你对客户理解逐步变清晰的过程,比任何一份静态的分析报告都更有说服力。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/26 2:11:46

Adobe Reader入门指南:PDF阅读、批注与表单填写实战

简介&#xff1a;本资源为Adobe Reader 11.x经典离线安装包&#xff0c;面向PDF文档处理初学者、办公人员及IT支持工程师&#xff0c;解决跨平台PDF稳定阅读、基础批注与安全打印等核心需求。压缩包共274个文件&#xff0c;总计102.16MB&#xff0c;包含12个可执行程序&#xf…

作者头像 李华
网站建设 2026/10/26 2:11:11

GDAL VS2017预编译库配置指南:从工程配置到调用验证

简介&#xff1a;面向在 Windows 平台使用 Visual Studio 2017 进行地理空间数据开发的中级 C 开发者&#xff0c;这份 GDAL 库资源包解决了环境搭建与项目链接的实际问题。包内共 106 个文件&#xff0c;以 104 个 .h 头文件为主体&#xff0c;涵盖 gdal_priv.h、ogr_geometry…

作者头像 李华
网站建设 2026/10/26 2:10:20

CoreliGrafxOrigins Pro中文版安装避坑与验证清单

简介&#xff1a;Corel iGrafx Origins 17.5.3.3 中文安装包面向企业流程管理、业务分析及流程建模人员&#xff0c;适合需要梳理跨部门协作流程、绘制规范图表并开展流程仿真验证的初、中级用户&#xff0c;也可为流程管理岗、信息化实施人员提供软件功能认知参考。软件提供 F…

作者头像 李华
网站建设 2026/10/26 2:09:57

Swift实况图制作:JPEG+MOV元数据配对与相册写入实战

简介&#xff1a;这份 Swift 实况图制作 demo 面向已有一定 iOS 基础、希望掌握 Live Photo 生成与展示的开发者&#xff0c;围绕静态 JPEG 与短视频 MOV 的合成流程&#xff0c;提供可运行的工程样例&#xff0c;用于解决实况图素材匹配、PHLivePhoto 创建及界面预览等实践问题…

作者头像 李华
网站建设 2026/10/26 2:08:59

Windows下Qt Creator+MSVC+CUDA环境配置与编译避坑

简介&#xff1a;面向Windows平台希望在Qt Creator中启用CUDA开发的初学者与中级开发者&#xff0c;这份示例工程围绕“qtcreator中添加cuda”的实际配置场景&#xff0c;提供可对照的工程组织方式&#xff0c;帮助理解如何在Qt项目中纳入.cu源文件并衔接nvcc编译流程。rar压缩…

作者头像 李华
网站建设 2026/10/26 2:08:43

DirectX 9.0 SDK 免安装版配置指南:VS2017~VS2022 通用实战

简介&#xff1a;本资源为DirectX 9.0 SDK免安装精简版&#xff0c;面向Windows平台C游戏开发初学者与图形编程学习者&#xff0c;解决传统SDK安装繁琐、环境配置复杂的问题&#xff0c;支持快速接入Direct3D 9、DirectSound、DirectInput等核心组件开发。压缩包为RAR格式&…

作者头像 李华