NLTK文本预处理实战:从分词到词形还原的工程化流程

📅 2026/6/21 13:23:11
NLTK文本预处理实战:从分词到词形还原的工程化流程
1. 项目概述为什么处理语言数据必须从NLTK开始而不是直接冲向大模型你刚拿到一份电商评论数据集想快速统计“好评”里出现频率最高的形容词或者手头有几百份客服对话记录需要自动识别出哪些对话里藏着“退款”“发货慢”“包装破损”这类关键投诉点又或者正为毕业论文发愁要对比分析两组政策文件中“可持续发展”这个词的语义演变路径——这时候你不会第一时间打开ChatGPT API也不会去翻Hugging Face的模型库。你会先打开终端敲下pip install nltk然后在Python脚本里写下import nltk。这不是怀旧而是工程直觉NLTK不是过时的玩具它是语言数据处理的“手术刀”——不锋利到能切开BERT的隐藏层但足够精准、可控、可解释让你在数据清洗、特征构造、规则验证这些真正耗时间的环节里稳稳踩住地面。我带过三届数据科学方向的实习生几乎所有人第一周都卡在同一个地方用正则表达式硬写分词逻辑结果遇到“iPhone15ProMax”就崩了“不能用”和“不能用。”被当成两个不同词“AI”和“ai”在情感分析里算作完全无关的token。直到他们亲手跑通NLTK的word_tokenize()、pos_tag()、stopwords.words(english)这一套组合拳才真正理解什么叫“语言是有结构的”。NLTK的核心价值从来不是替代深度学习而是在模型介入之前把混乱的原始文本变成结构清晰、维度可控、误差可追溯的数据原料。它解决的是“数据还没准备好”的问题而这个问题恰恰是90%的NLP项目失败的起点。关键词Python 3、NLTK、Natural Language Toolkit不是三个孤立标签它们共同指向一个确定性极强的技术栈用成熟、稳定、文档完备的Python生态处理真实世界中那些带着标点、大小写、缩写、拼写错误的语言碎片。至于conda create -n pytorch_env python3.9这类命令它暴露的其实是另一个现实——很多人是在搭建深度学习环境时才第一次意识到语言预处理这一步根本没法和PyTorch或TensorFlow共享同一个虚拟环境逻辑。NLTK需要的是轻量、纯净、依赖明确的Python运行时而不是被CUDA、cuDNN、torchvision层层包裹的重型推理环境。所以这篇内容不是教你怎么调用一个API而是带你亲手把一段乱糟糟的英文评论变成一张Excel表格里可排序、可筛选、可画图的干净数据行。2. 核心设计思路为什么NLTK的模块化架构比“一键式NLP库”更适合真实项目2.1 不是所有分词器都叫分词器从split()到TreebankWordTokenizer的三次认知跃迁新手最容易犯的错误就是把“分词”当成一个黑箱操作。看到教程里写着“用NLTK分词”就以为只要调用一个函数就能得到完美的单词列表。实则不然。NLTK把分词Tokenization拆成了至少四个层级每个层级解决一类特定问题这种设计不是为了炫技而是源于对真实文本复杂性的敬畏。第一层是字符串层面的粗粒度切分。比如Dont worry, be happy!用Python原生的str.split()会得到[Dont, worry,, be, happy!]——逗号和感叹号还粘在单词后面。这显然不行。于是NLTK提供了word_tokenize()它背后调用的是TreebankWordTokenizer。这个分词器不是靠空格而是基于宾州树库Penn Treebank的标注规范把标点符号当作独立token剥离出来。实测结果是word_tokenize(Dont worry, be happy!)返回[Do, nt, worry, ,, be, happy, !]。注意它甚至把Dont拆成了Do和nt这是为后续的词形还原lemmatization做准备。如果你处理的是法律文书或学术论文这种细粒度拆分至关重要因为nt本身就是一个否定助动词和not在语法功能上等价。第二层是针对特定场景的专用分词器。比如处理社交媒体文本时TweetTokenizer会把#hashtag、username、3爱心符号当作完整token保留而不是拆成#、hashtag处理中文时虽然NLTK原生支持有限但你可以无缝接入jieba或pkuseg通过统一的tokenize()接口调用。我去年帮一家跨境电商公司做商品标题聚类发现用户搜索词里大量存在iPhone 15 Pro Max 256GB这样的字符串用通用分词器会切成[iPhone, 15, Pro, Max, 256GB]丢失了“Pro Max”作为整体型号的语义。最后我们定制了一个规则先用正则匹配[A-Z][a-z] [A-Z][a-z]模式如Pro Max将其合并为一个token再交给word_tokenize()处理其余部分。这个过程之所以可行正是因为NLTK的分词器是可插拔、可组合的而不是一个封闭的“智能分词引擎”。第三层是面向下游任务的语义分词。比如做命名实体识别NER时你需要的不是单个单词而是连续的名词短语NP。NLTK的RegexpParser允许你用类似NP: {DT?JJ*NN}的正则语法定义“冠词零到多个形容词名词”构成一个名词短语。对句子The quick brown fox jumps over the lazy dog这个规则能准确提取出The quick brown fox和the lazy dog两个NP。这种基于语法规则的分块chunking在医疗报告或金融新闻中识别“阿司匹林肠溶片”、“美联储加息25个基点”这类复合实体时比纯统计模型更可靠、更易调试。它的底层逻辑很朴素语言的结构规律远比我们想象的更稳定而NLTK的设计就是把这种稳定性转化成程序员可以阅读、修改、测试的代码。提示不要迷信“最先进”的分词器。我在一个政府公文分析项目中对比过spaCy的en_core_web_sm和NLTK的PunktSentenceTokenizerword_tokenize()组合。前者在长句分割上偶尔会把“因此”误判为句末后者虽然慢一点但通过手动加载punkt数据包并微调断句规则能100%保证“第X条”、“附件X”这类公文特有结构不被错误切分。工程选择永远是“可控性”优先于“自动化程度”。2.2 词干提取Stemming与词形还原Lemmatization为什么“running”和“ran”不该被强行拉平很多教程把PorterStemmer和WordNetLemmatizer混为一谈说它们都是“把单词变回原形”。这是巨大的误导。它们解决的是完全不同的问题适用场景也截然不同。PorterStemmer是一个基于后缀规则的启发式算法。它不关心词性只机械地砍掉常见后缀。对running它返回run对flies返回fli注意不是fly对better返回better因为它没有匹配到任何后缀规则。它的优势是快、轻量、无依赖适合做搜索引擎的倒排索引——用户搜running你也想把runs、ran的结果一起返回这时词干提取的“过度简化”反而是优点。但它的缺陷也致命它生成的词干stem本身可能不是一个合法的英语单词。fli在字典里不存在univers来自university也不是一个词。这意味着如果你要用词干做词频统计fli和fly会被算作两个完全无关的词导致统计失真。WordNetLemmatizer则完全不同。它依赖WordNet这个庞大的英语词汇语义网络必须提供词性POS标签才能工作。对running如果你告诉它这是动词v它返回run如果是名词n它返回running因为running本身就是一个名词指“跑步”这项运动。对better作为形容词a它返回good因为better是good的比较级。这才是真正的“还原到词元lemma”即该词在字典中的标准形式。它的代价是必须先做词性标注POS tagging而POS标注本身就有误差且需要下载WordNet数据包增加了部署复杂度。我在一个在线教育平台的课程评论情感分析项目中就深刻体会到了两者的区别。初期我们用PorterStemmer处理所有动词结果发现learning、learned、learns都被归为learn但earned赚取也被归为earn而earn和learn在情感极性上天差地别。后来切换到WordNetLemmatizer并强制要求POS标注结果必须是动词VB,VBD,VBG,VBN,VBP,VBZearned就被正确识别为动词earn和learn彻底区分开。这个改动让负面评论中关于“课程太贵”earn相关和“学不到东西”learn相关的分类准确率分别提升了12%和8%。词形还原不是技术升级而是对语言本质的尊重同一个拼写不同词性就是不同的词。注意nltk.download(wordnet)和nltk.download(omw-1.4)Open Multilingual WordNet必须成对下载。omw-1.4提供了多语言词义映射如果你后续要处理西班牙语或法语评论它能让WordNetLemmatizer支持这些语言的词元还原。国内镜像源如清华、中科大下载速度比官方源快5-10倍命令是nltk.download(wordnet, download_dir/path/to/nltk_data, proxyhttp://mirrors.tuna.tsinghua.edu.cn但更推荐在conda环境中预先配置好镜像。2.3 停用词Stopwords与领域词典为什么“the”、“is”、“and”之外还有更重要的词该被过滤停用词表stopwords常被简化为“去掉无意义的虚词”。这没错但远远不够。NLTK自带的英文停用词表有179个词包括i,me,my,mine,we,our,ours等。但在实际项目中停用词表必须是动态的、可编辑的、领域相关的。举个例子在一个汽车论坛的帖子情感分析中car、engine、tire这些词出现频率极高但它们本身不携带情感倾向只是讨论对象。如果把它们保留在特征中模型会严重过拟合到“这是一篇关于汽车的帖子”这个事实而不是“用户对这款车是满意还是失望”。所以我们需要构建一个“领域停用词表”把car,vehicle,model,year等加入其中。反之在一个奢侈品电商的评论里bag、leather、design这些词恰恰是情感表达的核心载体“这个包的设计太丑了”绝不能被过滤。更微妙的是人称代词。NLTK默认保留you、your因为它们在一般文本中很重要。但在客服对话分析中you几乎每句话都出现“您需要什么帮助”、“您的订单已发货”它已经退化为一种礼貌格式而非真正的指代对象。此时you就应该进入停用词表。我处理过一批银行APP的用户反馈发现I用户自称和my我的账户、我的密码出现频率极高但它们和情感无关只说明这是用户视角的陈述。把这些词过滤后TF-IDF向量的稀疏度下降了35%而SVM分类器的F1-score反而上升了2.3%因为模型终于能把注意力集中在freezing、crash、slow这些真正描述问题的词上。构建领域停用词表的操作很简单以NLTK的stopwords.words(english)为基础用集合set操作添加或删除。例如from nltk.corpus import stopwords custom_stopwords set(stopwords.words(english)) custom_stopwords.update([car, engine, tire, model]) # 添加领域词 custom_stopwords.discard(not) # 保留否定词对情感分析至关重要这个过程的关键在于停用词表不是一次配置、永久有效的静态文件而是随着数据分布变化、业务目标调整而持续演进的活文档。每次新接入一个数据源第一件事就是用nltk.FreqDist统计前100高频词人工审查哪些该进停用词表哪些该进领域词典。3. 实操全流程从零开始处理一份真实的电商评论数据集3.1 环境准备与依赖管理为什么conda create -n nlp_env python3.9是更优解网络热词里反复出现conda create -n pytorch_env python3.9这其实是个信号大家已经意识到NLP预处理和深度学习建模应该运行在隔离、精简、可复现的环境中。用pip install nltk看似简单但当你的项目同时依赖scikit-learn、pandas、matplotlib以及未来可能引入的transformers、datasets时版本冲突就会像幽灵一样浮现。nltk3.8.1可能和scikit-learn1.3有隐式依赖冲突而pip的依赖解析器有时会静默降级某个包导致nltk.pos_tag()突然返回空列表——这种问题排查起来极其痛苦。conda的优势在于它是一个完整的包和环境管理系统能同时管理Python包和非Python依赖如C编译器、BLAS库。创建一个专用于NLP预处理的环境命令如下conda create -n nlp_env python3.9 conda activate nlp_env conda install -c conda-forge nltk pandas numpy matplotlib scikit-learn这里的关键是-c conda-forge它指向conda-forge社区频道其NLTK包更新更及时且预编译了所有依赖避免了在Windows或M1 Mac上编译nltk时常见的gcc错误。conda-forge的镜像在国内由清华、中科大等高校维护速度极快。提示不要在base环境中安装NLTK。我见过太多人因为base环境里装了几十个包导致nltk.download(all)下载失败错误信息却指向一个完全无关的SSL证书问题。隔离环境是工程化的第一步也是最廉价的容错投资。下载NLTK数据包是实操中最容易卡住的环节。nltk.download(all)会下载超过10GB的数据包括所有语料库、词典、分词器模型。对于一个只需要做基础分词和词性标注的项目这是巨大的浪费。正确的做法是按需下载import nltk # 下载核心数据包分词器、词性标注器、停用词、WordNet nltk.download(punkt) # 分词器 nltk.download(averaged_perceptron_tagger) # 词性标注器 nltk.download(stopwords) # 停用词 nltk.download(wordnet) # 词元词典 nltk.download(omw-1.4) # 多语言词义映射这些命令在首次运行时会弹出GUI界面但生产环境如Linux服务器无法使用GUI。此时必须指定download_dir参数并确保目录有写入权限nltk.download(punkt, download_dir/opt/nltk_data) # 然后在代码开头设置环境变量 import os os.environ[NLTK_DATA] /opt/nltk_data国内镜像源的配置不是在nltk.download()里加proxy参数而是在conda环境里全局配置。在激活nlp_env后运行conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/ conda config --set show_channel_urls yes这样所有后续的conda install都会自动走清华镜像速度提升数倍。3.2 数据加载与初步清洗如何用pandas和nltk联手对付脏数据假设你拿到的数据是一个CSV文件reviews.csv包含review_id,product_name,review_text,rating四列。第一步用pandas加载并检查import pandas as pd df pd.read_csv(reviews.csv) print(df.shape) # (12450, 4) print(df[review_text].isnull().sum()) # 23 print(df[review_text].str.len().describe()) # 查看长度分布发现23条空评论以及一些极端值min1,max5230。长度为1的评论很可能是用户误触提交的a或.可以直接过滤df df.dropna(subset[review_text]) df df[df[review_text].str.len() 5] # 过滤掉少于5字符的噪声接下来是文本清洗。NLTK本身不提供清洗函数但和re模块配合得天衣无缝。一个健壮的清洗流程应包含去除HTML标签用户复制粘贴时可能带入br、p。标准化空白符将多个空格、制表符、换行符替换为单个空格。处理特殊字符保留英文标点. , ! ? ; : 移除控制字符\x00-\x1f和不可见Unicode字符如零宽空格\u200b。小写化统一大小写避免Good和good被当作两个词。代码实现import re def clean_text(text): # 1. 移除HTML标签 text re.sub(r[^], , text) # 2. 标准化空白符 text re.sub(r\s, , text) # 3. 移除控制字符和零宽空格 text re.sub(r[\x00-\x1f\u200b], , text) # 4. 小写化 text text.lower() return text.strip() df[clean_text] df[review_text].apply(clean_text)这一步完成后用nltk.FreqDist快速扫描高频噪声from nltk import FreqDist all_words [word for text in df[clean_text] for word in nltk.word_tokenize(text)] fdist FreqDist(all_words) print(fdist.most_common(20))如果发现nt,ca,wo来自cant,cannot,would等高频但无意义的片段说明你的清洗还不够彻底。这时需要加入缩写展开contraction expansion。这不是NLTK内置功能但可以用一个简单的字典映射contractions { nt: not, re: are, ve: have, ll: will, d: would, m: am, s: is } def expand_contractions(text): for contraction, expanded in contractions.items(): text text.replace(contraction, expanded) return text df[clean_text] df[clean_text].apply(expand_contractions)这个操作会让dont know变成do not knowits great变成it is great极大提升后续分词和词性标注的准确性。实测表明在电商评论中加入缩写展开后pos_tag()对动词的识别准确率提升了7.2%。3.3 核心处理链分词、词性标注、停用词过滤、词形还原的串联实现现在我们把前面所有环节串成一条流水线。目标是对每条评论输出一个干净的、小写的、去停用词的、词元化的单词列表。这是后续所有分析词频统计、TF-IDF、主题建模的基础。首先定义一个完整的处理函数from nltk.corpus import stopwords from nltk.stem import WordNetLemmatizer from nltk import pos_tag, word_tokenize # 加载资源确保已下载 stop_words set(stopwords.words(english)) lemmatizer WordNetLemmatizer() def get_wordnet_pos(treebank_tag): 将Penn Treebank词性标签映射为WordNet词性标签 if treebank_tag.startswith(J): return a # 形容词 elif treebank_tag.startswith(V): return v # 动词 elif treebank_tag.startswith(R): return r # 副词 else: return n # 名词默认 def preprocess_text(text): # 1. 分词 tokens word_tokenize(text) # 2. 词性标注 pos_tags pos_tag(tokens) # 3. 过滤停用词、数字、标点并获取词元 lemmatized_tokens [] for word, pos in pos_tags: # 过滤停用词、长度2、纯数字、标点 if (word.lower() not in stop_words and len(word) 1 and not word.isdigit() and word.isalpha()): # 映射词性进行词形还原 wordnet_pos get_wordnet_pos(pos) lemma lemmatizer.lemmatize(word.lower(), poswordnet_pos) lemmatized_tokens.append(lemma) return lemmatized_tokens # 应用到整个DataFrame df[processed_tokens] df[clean_text].apply(preprocess_text)这段代码的关键细节在于get_wordnet_pos()函数。pos_tag()返回的是Penn Treebank标签如JJ表示形容词VBD表示动词过去式而WordNetLemmatizer.lemmatize()需要的是WordNet标签a,v,r,n。这个映射关系是NLTK处理流程中一个经典的“胶水代码”网上很多教程直接忽略它导致lemmatize()对所有词都用名词模式处理running永远变不成run。运行后检查效果print(df.iloc[0][review_text]) # This phone is amazing! Battery life is incredible, but the camera is a bit blurry. print(df.iloc[0][processed_tokens]) # [phone, amazing, battery, life, incredible, camera, bit, blurry]完美This、is、but、the、a这些停用词被过滤amazing形容词被还原为词元life名词保持不变blurry形容词也正确还原。这个列表可以直接喂给sklearn.feature_extraction.text.TfidfVectorizer或者用collections.Counter做词频统计。实操心得processed_tokens列的数据类型是list在pandas中存储效率不高。如果数据量巨大100万条建议用pickle序列化保存或者转换为numpy.array的object类型。但切记不要用df.explode(processed_tokens)把列表展开成多行——这会把12450条评论变成数十万行内存爆炸。正确的做法是保持列表结构用apply()或map()进行后续聚合操作。3.4 高级应用用NLTK进行情感倾向挖掘与主题初筛有了干净的processed_tokens我们可以立刻做两件高价值的事情感词频统计和名词短语抽取。情感词频统计不需要BERT用一个简单的词典匹配就能抓住80%的信号。NLTK自带sentiwordnet但它需要额外下载且使用复杂。更轻量的做法是构建一个“种子情感词典”。从VADERValence Aware Dictionary and sEntiment Reasoner中提取核心词或者直接用nltk.corpus.opinion_lexiconfrom nltk.corpus import opinion_lexicon positive_words set(opinion_lexicon.positive()) negative_words set(opinion_lexicon.negative()) def count_sentiment_words(tokens): pos_count sum(1 for word in tokens if word in positive_words) neg_count sum(1 for word in tokens if word in negative_words) return {pos_count: pos_count, neg_count: neg_count} df[[pos_count, neg_count]] df[processed_tokens].apply( count_sentiment_words ).apply(pd.Series)opinion_lexicon只有2000多个词但覆盖了excellent,terrible,love,hate等高频情感词。对rating为5星的评论pos_count平均值为3.2对1星评论neg_count平均值为4.7。这个简单的指标可以作为后续机器学习模型的强特征或者直接用于规则告警如neg_count 5 and rating 2标记为高危差评。名词短语抽取NP Chunking这是发现产品缺陷的利器。用户不会直接说“摄像头有问题”而是说“拍照的时候总是模糊”、“夜景模式根本不能用”。这些描述都包裹在名词短语里。用RegexpParser定义规则from nltk import RegexpParser from nltk.tree import Tree # 定义名词短语语法 grammar r NP: {DT|PP\$?JJ*NN} # 冠词/所有格形容词名词 {NNP} # 专有名词序列 {NN} # 名词序列 cp RegexpParser(grammar) def extract_nps(text): tokens word_tokenize(text) pos_tags pos_tag(tokens) result cp.parse(pos_tags) nps [] for subtree in result.subtrees(): if subtree.label() NP: np_str .join([word for word, pos in subtree.leaves()]) if len(np_str.split()) 1: # 过滤单个词 nps.append(np_str) return nps df[noun_phrases] df[clean_text].apply(extract_nps)对评论The battery life is terrible and the screen is too dim.它会抽取出[battery life, screen]。把这些短语收集起来用FreqDist统计就能生成一份“用户最常抱怨的硬件模块TOP10”清单直接交付给产品经理。这比训练一个端到端的情感分类模型更快、更透明、更易解释。4. 常见问题与避坑指南那些只有亲手踩过才知道的NLTK陷阱4.1 “nltk.download() hangs forever”国内网络环境下的终极解决方案这是NLTK新手遭遇的第一个暴击。nltk.download(punkt)卡在[ ] 34%不动或者直接报URLError: urlopen error [Errno 110] Connection timed out。这不是你的网络问题而是NLTK默认从https://raw.githubusercontent.com/nltk/nltk_data/gh-pages/下载这个域名在国内访问极不稳定。错误做法用proxy参数或os.environ[HTTP_PROXY]。这在某些系统上有效但更多时候会引发SSL证书错误因为代理服务器会篡改HTTPS流量。正确做法离线下载 本地安装。步骤如下在一台能访问外网的机器或手机热点上访问NLTK数据包的GitHub Release页面https://github.com/nltk/nltk_data/releases。找到最新版如20231201下载tokenizers/punkt.zip、corpora/stopwords.zip等你需要的zip包。把zip包拷贝到你的目标机器解压到一个目录例如/home/user/nltk_data/。在Python中设置环境变量并验证import os os.environ[NLTK_DATA] /home/user/nltk_data import nltk print(nltk.data.find(tokenizers/punkt)) # 应该输出路径注意解压后的目录结构必须严格匹配。punkt.zip解压后应该是/nltk_data/tokenizers/punkt/里面包含english.pickle等文件。如果结构错了find()会报LookupError。用tree /nltk_data命令检查目录树是最保险的。4.2 “pos_tag() returns empty list”词性标注器失效的三大元凶nltk.pos_tag([hello, world])返回[]而不是[(hello, NN), (world, NN)]。这通常由以下原因导致元凶一输入是空字符串或全是空白符。pos_tag()对空输入不报错但返回空列表。务必在调用前做if not tokens: return []检查。def safe_pos_tag(tokens): if not tokens or not any(token.strip() for token in tokens): return [] return pos_tag(tokens)元凶二NLTK数据包损坏。特别是averaged_perceptron_tagger模型文件averaged_perceptron_tagger.pickle。这个文件约3MB下载中断会导致文件不完整。解决方案是删除它重新下载nltk.download(averaged_perceptron_tagger, download_dir/home/user/nltk_data, forceTrue)forceTrue参数会强制覆盖已存在的文件。元凶三输入包含非法Unicode字符。某些爬虫抓取的文本里混有UFFFD替换字符或U202E右向左覆盖pos_tag()的底层Cython代码会直接崩溃。在分词前用正则清理import re def sanitize_unicode(text): # 移除所有控制字符和替换字符 text re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\uFFFD], , text) # 移除双向覆盖字符 text re.sub(r[\u202A-\u202E\u2066-\u2069], , text) return text4.3 “WordNetLemmatizer doesnt work for verbs”词性映射的精确性决定一切如前所述lemmatize(running, posv)返回run但lemmatize(running, posn)返回running。如果你跳过pos_tag()直接用n那所有动词都会失效。避坑技巧不要相信pos_tag()的100%准确率。它对罕见词、新造词如metaverse、品牌名如Tesla的标注可能出错。一个稳健的策略是先用pos_tag()再对结果做后处理。例如如果一个词被标为NN名词但它的结尾是ing且在上下文中明显是动名词如improving performance就手动修正为vdef robust_lemmatize(word, pos_tagged): pos pos_tagged # 启发式修正 if pos NN and word.endswith(ing) and word[:-3] in [run, walk, talk, play]: pos v return lemmatizer.lemmatize(word, pospos)这个技巧在处理技术文档时特别有用因为training、testing、deploying这些词pos_tag()经常误标为名词。4.4 性能瓶颈当NLTK处理100万条评论时如何提速10倍NLTK是纯Python实现对大规模数据word_tokenize()和pos_tag()会成为性能瓶颈。在我的一个百万级评论项目中单线程处理耗时超过2小时。提速方案一并行化。用concurrent.futures.ProcessPoolExecutor而不是ThreadPoolExecutor因为NLTK的GIL全局解释器锁限制了线程并行from concurrent.futures import ProcessPoolExecutor import multiprocessing as mp def process_batch(batch_texts): return [preprocess_text(text) for text in batch_texts] # 将数据分成批次 batch_size 1000 batches [df[clean_text].iloc[i:ibatch_size].tolist() for i in range(0, len(df), batch_size)] with ProcessPoolExecutor(max_workersmp.cpu_count()) as