做数据开发的同学应该都有过这种经历:辛辛苦苦清洗好的DataFrame,里面明明躺着一个看起来非常规整的字符串列,比如"1,2,3,4"或者分类标签"apple,banana,cherry",想着直接丢进MLlib里训练一把模型,结果fit()一跑就直接抛异常,提示字段类型不支持。原因很简单:Spark的机器学习算法只认VectorUDT类型的列,也就是DenseVector或SparseVector,普通的String列在它们眼里就是一堆没法下口的生肉。这篇文章就想把“String类型怎么变成向量”这件事彻底讲清楚,包括不同字符串语义对应的方案、三种可直接抄作业的转换方法、常见报错排查,以及我在真实业务里踩过的那些坑。适合正在用PySpark做特征工程的朋友,尤其是被StringIndexer、OneHotEncoder、HashingTF这几个组件搞得一头雾水的同学。1. 为什么String列会被挡在MLlib门外1.1 一个最常见的报错现场我在给某跨平台系统做用户行为特征时,遇到过一个非常典型的场景:业务方把用户最近浏览的商品ID以"1001,2033,4099"这种格式存在一张宽表里,每个用户一行,看起来信息非常规整。我当时的处理思路很直接:直接把这列丢给VectorAssembler,让它跟其他数值特征拼成一个特征向量。结果运行到一半就报错,日志里反复出现一句话,大意是DataFrame doesn't support field type: StringType,也就是VectorAssembler不接受String类型的字段。这个报错其实揭示了一个底层事实:Spark MLlib的训练器、评估器和特征工具,默认只认数值型字段和向量型字段。原因也不难理解,模型在底层算的是矩阵和向量运算,字符串不能直接参与距离计算、梯度下降或者树分裂。哪怕是一个很简单的逻辑回归,输入也得是VectorUDT类型的列,因为每个特征维度必须对应一个可计算的数值位置。说白了,String只是给人看的,模型看不懂,必须先转化成它能消化的数学结构。那为什么有时候StringIndexer处理过的列能直接训练?因为StringIndexer输出的是数值索引列,比如把"apple"映射成0.0,"banana"映射成1.0,这个输出已经是数值了,模型自然能读。但如果你要把多个特征拼成一个完整的特征向量,VectorAssembler仍然要求每个输入字段要么是数值类型,要么是向量类型,普通String字段永远不行。这就是为什么“String转向量”几乎是每个Spark机器学习项目绕不开的环节。1.2 SparseVector和DenseVector,到底选哪个在动手转换之前,先弄清楚稀疏向量和稠密向量这两个概念的差异,能帮你省很多不必要的折腾。稠密向量(DenseVector)说白了就是一个普通的数组,每个位置都存一个值,没有值就用0.0补齐。例如Vectors.dense(1.0, 0.0, 3.0, 0.0),底层就是一个长度4的Double数组。这种表示方式直观、访问快,适合维度不高(比如几十到几百维)的场景,缺点就是浪费内存,如果特征维度上万但大部分是0,存一堆0实在不划算。稀疏向量(SparseVector)则只记录非零位置和对应的值,例如Vectors.sparse(4, [0, 2], [1.0, 3.0]),表示这是一个长度为4的向量,只有第0位和第2位有值。这种结构在文本特征、独热编码、高维稀疏特征场景下能省下大量内存,而且计算起来也更快,因为不需要遍历所有维度。从Spark自身的行为也能看出设计倾向:OneHotEncoder输出的就是SparseVector,因为它本质上是把某个索引位置置1、其余全是0;HashingTF输出的也是SparseVector,因为分词后的哈希表大部分位置都是空的。而你自己把一个字符串数组直接解析出来的结果,通常是DenseVector,因为每一个维度都确实有实际数值。选哪个没有绝对标准,核心就一句话:维度不高、非零值占多数就用Dense;维度高到万级别、绝大多数位置都是0,就用Sparse。2. 先分清你的String属于哪种语义2.1 数值字符串:解析成数组再转向量“数值字符串”指的是内容本身就是一堆数字,只是被塞进了String列。典型形态包括:"1.0, 2.0, 3.5""[1.0,2.0,3.5]""1.0|2.0|3.5""1 2 3.5"(空格分隔)这类字符串处理起来最简单,因为语义就是“按分隔符拆开,逐个转成Double,装进向量”,不涉及任何映射或编码。我在实际业务里见过最多的是第一种,即逗号分隔的纯数字串,有些系统导出数据时就喜欢这么玩,看起来方便但给下游留了不少活。要注意的是分隔符可能不止一种,有的数据源会混着逗号和竖线,有的会带括号,这些都得先清洗。还有一点容易被忽略:字符串里的数字可能是整数形态"1,2,3",也可能是浮点"1.0,2.5,3.0",解析成DoubleType是没问题的,但如果你用StringIndexer去处理这种列,那就完全搞错语义了,因为StringIndexer会把这些数值当成离散标签,比如把"1.0"和"2.0"当成两个不同的类别,这显然不是你想要的效果。2.2 类别字符串:需要索引和独热编码另一种更常见的场景是,字符串本身是分类标签,例如:用户的性别:"M"/"F"商品的品类:"电子"/"服饰"/"食品"城市:"北京"/"上海"/"广州"这种字符串不能直接拆成数组,因为apple跟banana之间没有数值大小关系,你不能说apple = 1.0、banana = 2.0就直接丢给模型,否则树模型可能做出一堆没意义的切分,线性模型更是会被这个伪造的排序带偏。正确的做法是先StringIndexer转成数值索引,再用OneHotEncoder做独热编码。独热编码的输出天然就是稀疏向量:每个类别对应一个位置,当前样本属于哪个类别,那个位置就是1,其余全是0。举个例子,三个城市标签"北京"、"上海"、"广州"分别索引为0.0、1.0、2.0,那么一条"北京"的记录独热编码后就变成SparseVector(3, {0: 1.0}),只存了一个位置。这里容易踩一个误区:如果分类变量的基数很低,比如只有两三个取值,独热编码是合理的;但如果基数有几百上千,比如用户ID、商品ID这种,独热编码会直接把特征维度撑爆,而且大部分位置都是0,效果和性能都很差。遇到这种高基数类别,我更推荐直接用数值索引列当作特征,或者用目标编码等方式,不要硬上独热。2.3 自由文本:分词之后哈希稀疏化第三种场景是真正的文本内容,比如评论、描述、日志文本,例如:"spark is fast and powerful""机器学习模型的特征工程很关键"这种字符串既不是数值,也不是简单的离散标签,而是需要经过分词、清洗、特征映射才能变成向量。Spark里最经典的方案是Tokenizer+HashingTF:先Tokenizer把句子切成单词列表,再用HashingTF把单词哈希到固定维度的稀疏向量里。HashingTF内部会维护一个固定大小的数组,每个单词通过哈希函数映射到一个索引位置,并统计词频,最终输出一个大部分位置为0的稀疏向量。这种方案的好处是速度快、不需要预建词典,坏处是哈希冲突不可避免,不同单词可能落到同一个桶里,但只要维度设置得足够大,冲突对模型的影响通常可控。HashingTF适合海量文本的粗粒度特征,如果追求更精细的效果,可以换CountVectorizer,它需要先扫描语料建词典,输出的是基于真实词典的稀疏向量,可解释性更强,但代价是更吃内存和时间。3. 三种可直接照抄的转换方案3.1 方法一:split + cast + array_to_vector 快速做DenseVector如果你的String是“数值字符串”,也就是用分隔符连接的Double数组形态,array_to_vector是效率最高的路子,不需要写UDF,全程走原生函数。先看完整例子:from pyspark.sql import SparkSession, functions as F from pyspark.sql.types import ArrayType, DoubleType from pyspark.ml.functions import array_to_vector spark = SparkSession.builder.master("local[*]").appName("string_to_vector").getOrCreate() df = spark.createDataFrame([ (1, "1.0,2.0,3.5"), (2, "4.0,5.0,6.0"), (3, "7.5,8.8,9.9") ], ["id", "features_str"]) df_dense = df.withColumn( "features_arr", F.split(F.col("features_str"), ",").cast(ArrayType(DoubleType())) ).withColumn( "features", array_to_vector(F.col("features_arr")) ) df_dense.printSchema() df_dense.show(truncate=False)这段代码做的事情很清晰:F.split按逗号把字符串拆成字符串数组,.cast(ArrayType(DoubleType()))把这个数组里每个元素从String变成Double,最后array_to_vector把数组列转成稠密向量列。这里每一步都有讲究,先拆后cast是有顺序的,如果直接对String列做cast,得到的结果不是我们想要的数组,而是乱码或null。如果你的Spark版本比较老,比如2.4或更早,array_to_vector还不存在,可以退而求其次自己写一个UDF:from pyspark.sql.types import ArrayType, DoubleType from pyspark.ml.linalg import Vectors, VectorUDT def array_to_vector_udf(arr): if arr is None: return None return Vectors.dense(arr) to_vec = F.udf(array_to_vector_udf, VectorUDT()) df_dense = df.withColumn( "features_arr", F.split(F.col("features_str"), ",").cast(ArrayType(DoubleType())) ).withColumn( "features", to_vec(F.col("features_arr")) )注意,这里UDF返回类型必须是VectorUDT(),否则DataFrame不知道这一列是向量类型,后边的训练器还是认不出来。我自己写这个UDF的时候曾经返回过Python的list,结果跑模型直接报Unsupported data type,非常痛苦。3.2 方法二:StringIndexer + OneHotEncoder 输出SparseVector处理“类别字符串”的完整链路是这样的:from pyspark.ml.feature import StringIndexer, OneHotEncoder df_cat = spark.createDataFrame([ (0, "apple"), (1, "banana"), (2, "apple"), (3, "cherry") ], ["id", "fruit"]) indexer = StringIndexer(inputCol="fruit", outputCol="fruit_index") df_idx = indexer.fit(df_cat).transform(df_cat) encoder = OneHotEncoder(inputCol="fruit_index", outputCol="fruit_vec") df_enc = encoder.fit(df_idx).transform(df_idx) df_enc.show(truncate=False)输出结果里fruit_vec是SparseVector类型。比如apple对应的可能是SparseVector(3, {0: 1.0})。之所以看到的是稀疏而非稠密,是因为独热编码天然稀疏:3个类别就3个位置,其中2个是0,只有1个是1,Spark就干脆只存那个非零位置。StringIndexer这里有个细节需要注意:它会按频次对类别排序,出现频率最高的类别索引为0,次高的索引为1,以此类推。比如上面例子里apple出现了两次,就会拿到索引0;banana和cherry各出现一次,分到索引1和2。如果业务上有明确顺序,建议用StringIndexer的stringOrderType参数改排序方式,或者干脆自定义映射。还有一点可能很多新手不知道:在Spark 3.3之前,这个编码器的名字叫OneHotEncoderEstimator,3.3版本之后才精简成OneHotEncoder并支持同时输入多列。如果你的集群是Spark 2.x或早期3.x,还在用旧的OneHotEncoder,会麻烦很多,因为它一次只接收一个输入列,而且不能像新版本那样直接用inputCols和outputCols传多个列名。我见过不少人在版本切换时被这个类名改动卡住,代码在网上抄了一个新的,结果部署到老集群直接报ClassNotFound或者NoSuchMethodError。3.3 方法三:Tokenizer + HashingTF 把文本转成SparseVector处理自由文本的标准流水线如下:from pyspark.ml.feature import Tokenizer, HashingTF df_text = spark.createDataFrame([ (0, "hello world spark"), (1, "spark is fast"), (2, "hello spark ml") ], ["id", "text"]) tokenizer = Tokenizer(inputCol="text", outputCol="words") df_words = tokenizer.transform(df_text) hashing = HashingTF(inputCol="words", outputCol="features", numFeatures=10000) df_hash = hashing.transform(df_words) df_hash.show(truncate=False)HashingTF默认输出SparseVector,numFeatures控制了向量的维度,这个参数非常关键。维度太小,哈希冲突率会升高,不同单词可能映射到同一个位置,导致特征信息丢失;维度太大,向量虽然依然稀疏,但数据整体体积变大,训练开销也上去。没有绝对最优值,我在业务里一般从2^15到2^18这个区间试,文本量越大,维度越要偏大一点。如果文本里数字和符号也多,建议在Tokenizer前面加一个RegexTokenizer,用正则控制切分规则,比如只保留字母、数字和中文。Spark原生的Tokenizer是基于空格和标点切分的,对中文没有任何分词能力,它对中文的处理方式是整句当成一个词。你如果做中文文本特征,千万别直接用Tokenizer,那出来的特征基本不能用。中文场景我一般借助分词库先处理好,再把切好的结果以空格分隔符写入一个临时列,然后用Tokenizer去切,或者干脆自定义一个UDF把分词结果数组直接变成词序列。3.4 自己写UDF?先看清这三条边界很多场景下,Spark内置组件覆盖不了,比如字符串格式非常特殊,或者要在一行里同时做多种逻辑转换。这时候就得自己写UDF,但写UDF有代价,得想清楚。第一,UDF的性能远低于内置函数。PySpark的UDF在大数据量下需要把JVM里的数据序列化到Python进程再传回来,这个开销非常大。我在一次处理几千万行数据时,用array_to_vector原生函数跑了几分钟,换成同样的逻辑写UDF跑了几十分钟,差距不止一点。所以能不用UDF就不用UDF,非要用的场景也尽量把UDF的逻辑压在单行计算上,不要在里面做太重的操作。第二,UDF的返回类型必须精确声明。上面例子里返回VectorUDT()是正确的,如果你返回Python list或者误把Vectors.dense和Vectors.sparse混着返回,Spark的序列化器会当场给你报错。我还见过有人写数组转向量的UDF时,函数名和变量名跟pyspark.ml.linalg.Vectors重名了,结果一调用就报'module' object is not callable,排查半天才发现是命名冲突。第三,UDF对null的处理要主动兜底。DataFrame里某个字符串列有null,UDF默认接收到的是None,你不做判断直接split会抛异常。习惯性在函数开头加一个if arr is None: return None,能避免后面很多麻烦。这一点我后面专门再展开说。4. 把向量送进模型前的最后一步4.1 VectorAssembler多列合并成特征向量实际建模很少只有一个特征,一个典型的特征向量可能由三部分组成:原始的数值列(比如年龄、收入)、独热编码后的稀疏向量列(比如城市)、哈希处理后的文本特征向量列。Spark不会自动帮你把这三种列拼到一起,需要VectorAssembler出手:from pyspark.ml.feature import VectorAssembler assembler = VectorAssembler( inputCols=["age", "income", "fruit_vec", "text_features"], outputCol="final_features" ) final_df = assembler.transform(df_enc_with_text)VectorAssembler要求所有inputCols都是数值类型或向量类型,任何一个普通String列混进来都会直接报错。这也是前面为什么非要把String转成向量的核心原因之一:就算VectorAssembler能容忍其他类型,它也不会帮你把字符串自动转换。使用VectorAssembler还有一个隐性规则:向量列的维度会被自动拼接。比如fruit_vec维度是3,text_features维度是10000,拼出来的final_features维度就是3加10000再加两个数值特征的维数。你不需要手动计算总维度,VectorAssembler内部会处理,这在后面做模型解释或者系数反解时反而有帮助,因为你始终知道每个区块对应哪个原始特征。4.2 完整Pipeline示例:从原始String到可直接训练的DataFrame把前面几种方案组合起来,一条完整的链路是这样的:from pyspark.ml import Pipeline from pyspark.ml.feature import StringIndexer, OneHotEncoder, Tokenizer, HashingTF, VectorAssembler raw_df = spark.createDataFrame([ (1, "hello world", "apple", 28, 10000), (2, "spark is fast", "banana", 35, 25000), (3, "hello spark", "apple", 40, 30000) ], ["id", "text", "city", "age", "income"]) tokenizer = Tokenizer(inputCol="text", outputCol="words") hashing = HashingTF(inputCol="words", outputCol="text_features", numFeatures=100) city_indexer = StringIndexer(inputCol="city", outputCol="city_index") city_encoder = OneHotEncoder(inputCol="city_index", outputCol="city_vec") assembler = VectorAssembler( inputCols=["age", "income", "city_vec", "text_features"], outputCol="features" ) pipeline = Pipeline(stages=[tokenizer, hashing, city_indexer, city_encoder, assembler]) model = pipeline.fit(raw_df) result_df = model.transform(raw_df) result_df.select("id", "features").show(truncate=False)这一步跑通了,features列就是一个标准的VectorUDT列,直接可以传给LogisticRegression、RandomForestClassifier等模型训练器。用Pipeline的好处不只是代码规范,更重要的是把特征转换过程固化成一套可复用的流程,模型上线时用同一个Pipeline处理实时数据,不会出现训练和预测特征口径不一致的问题。我在实际业务中非常推荐把特征工程全链路Pipeline化,尤其是涉及到StringIndexer这类有状态组件的时候。StringIndexer在训练时学到的标签映射必须在下游预测时复用,你如果训练用Pipeline.fit生成模型,预测时直接调用同一个模型对象,麻烦最少。如果手工拆开各个Transformer,稍不留神预测时少跑一步或者重新fit了一遍,特征顺序就变了,模型预测结果直接废掉。5. 我踩过的坑:排查记录与经验笔记5.1 null和空字符串:看起来一样,踩进去完全不同字符串数据里最让人头疼的两种“脏值”就是null和空字符串。看起来差不多,但在Spark的行为上差异巨大。F.split(null, ",")的结果是null,不是空数组;F.split("", ",")的结果是[""],一个包含单个空字符串的数组。这两种情况转向量时表现完全不同:null变成null向量没问题,后面训练器会把它当成缺失值处理;[""]再cast成ArrayType(DoubleType()),数组里那个空字符串会转成null,最后array_to_vector遇到数组里有null元素,整个转换直接失败,报错往往是cannot convert array element null to vector。解决的思路很朴素,转换前先清洗:df_clean = df.withColumn( "features_str", F.when(F.trim(F.col("features_str")) == "", None).otherwise(F.col("features_str")) )把空字符串统一先转成null,后面所有处理都简单得多。写UDF的人更要注意,函数入口就对null做判断。我建议把这个习惯刻进脑子里,凡是写接收DataFrame列值的UDF,第一行永远是null判断,不要问为什么,问就是踩过坑。5.2 Spark版本不同导致“找不到类”这个坑我从2.4一直踩到3.4,印象太深了。最典型的例子就是前面说的OneHotEncoderEstimator和OneHotEncoder。假设你在网上找了一份新版本代码,直接拿到一个Spark 2.4的集群上跑,OneHotEncoder(inputCols=[...])这个构造方式根本不存在,要么报类不存在,要么报参数不一致。另一个版本差异是array_to_vector和vector_to_array这两个函数,它们是Spark 3.0引入的。如果你维护的脚本可能在不同Spark版本之间切换,建议写个检测逻辑,或者干脆统一用UDF方案,牺牲一点性能换兼容性。更隐蔽的版本差异藏在StringIndexer的参数上。早期版本的StringIndexer.setHandleInvalid("keep")可能不生效或者行为不同,导致预测时遇到训练集没见过的类别,直接把它映射到某个伪索引。这个坑在模型上线后如果新数据类别漂移就会突然爆发,表现为模型效果莫名其妙变差,但代码看起来毫无问题。5.3 解析精度和分隔符的坑字符串拆成Double的过程看着简单,实际坑也不少。最常见的是分隔符不一致,同一列数据里既有英文逗号也有中文逗号,或者某些行用竖线。我建议在split之前统一做一次字符串规整,比如把全角逗号替换成半角,再把多种分隔符统一成一种。步骤多两步,但省下的排查时间可不止两步。精度问题也很隐蔽。字符串里的数字可能是"0.1",但在Double里存的是0.1000000000000000055511151231257827,转成向量后数值本身会变化。如果你后续做的是比较严格的数值计算(比如算距离),这种精度误差虽然小,但积累起来会让人抓狂。出现极高精度要求时,可以考虑保留Decimal类型先做运算再转Double,或者直接用高精度库处理完再入向量。5.4 性能取舍:UDF、原生函数与序列化开销前面提过UDF和大数据量下的性能问题,这里再具体说说。字符串转向量这个操作,如果能用split加cast加array_to_vector原生组合,大部分情况下是够快的。但如果你的字符串格式极其诡异,必须用UDF,性能优化可以从两个方向下手:一是用pandas_udf批量处理,减少单行序列化次数;二是尽量把多个UDF合并成一个,避免同一行数据在Python和JVM之间反复横跳。我这边的实操经验是:几百万行以下,单写UDF问题不大;几千万行以上,UDF耗时很容易比模型训练都长,不优化基本没法用。优先让数据工程师在上游把字符串解析成数组,或者以结构化格式落盘,把脏活累活在上游干完,下游特征工程只负责轻量转换,这是对整个链路最友好的方案。6. 最后再分享一点实操体会做了这么多年特征工程,我越来越觉得字符串转向量这件事,本质上是在回答一个问题:你手里的字符串到底凭什么代表这个特征?是数值序列、离散标签,还是非结构化文本?三种语义对应三种完全不同的处理路径,选错一条,后面怎么调都别扭。我的建议是,在开始写代码之前,先花十分钟把你手头的String列都梳理一遍,按语义分类标注好。这一步省下的时间,远超过爬各种报错的时间。真的碰到字符串格式极其混乱的情况,也别硬撑,直接看能不能在上游数据导出时就改成结构化格式,比如多值用数的顺序和位置表达,分类用单独的整数编码,文本先做分词处理。上游多走一步,下游少跑十次排查。如果非要在本文最后留一句个人经验,那就是:能走原生函数就绝对不写UDF,能在Pipeline里固化流程就绝对不手工拼凑,能把空值统一成null就绝对不让它在转换中途冒头。这三条守好了,你的字符串转向量之路会顺畅非常多。
做数据开发的同学应该都有过这种经历:辛辛苦苦清洗好的DataFrame,里面明明躺着一个看起来非常规整的字符串列,比如"1,2,3,4"或者分类标签"apple,banana,cherry",想着直接丢进MLlib里训练一把模型,结果fit()一跑就直接抛异常,提示字段类型不支持。原因很简单:Spark的机器学习算法只认VectorUDT类型的列,也就是DenseVector或SparseVector,普通的String列在它们眼里就是一堆没法下口的生肉。这篇文章就想把“String类型怎么变成向量”这件事彻底讲清楚,包括不同字符串语义对应的方案、三种可直接抄作业的转换方法、常见报错排查,以及我在真实业务里踩过的那些坑。适合正在用PySpark做特征工程的朋友,尤其是被StringIndexer、OneHotEncoder、HashingTF这几个组件搞得一头雾水的同学。1. 为什么String列会被挡在MLlib门外1.1 一个最常见的报错现场我在给某跨平台系统做用户行为特征时,遇到过一个非常典型的场景:业务方把用户最近浏览的商品ID以"1001,2033,4099"这种格式存在一张宽表里,每个用户一行,看起来信息非常规整。我当时的处理思路很直接:直接把这列丢给VectorAssembler,让它跟其他数值特征拼成一个特征向量。结果运行到一半就报错,日志里反复出现一句话,大意是DataFrame doesn't support field type: StringType,也就是VectorAssembler不接受String类型的字段。这个报错其实揭示了一个底层事实:Spark MLlib的训练器、评估器和特征工具,默认只认数值型字段和向量型字段。原因也不难理解,模型在底层算的是矩阵和向量运算,字符串不能直接参与距离计算、梯度下降或者树分裂。哪怕是一个很简单的逻辑回归,输入也得是VectorUDT类型的列,因为每个特征维度必须对应一个可计算的数值位置。说白了,String只是给人看的,模型看不懂,必须先转化成它能消化的数学结构。那为什么有时候StringIndexer处理过的列能直接训练?因为StringIndexer输出的是数值索引列,比如把"apple"映射成0.0,"banana"映射成1.0,这个输出已经是数值了,模型自然能读。但如果你要把多个特征拼成一个完整的特征向量,VectorAssembler仍然要求每个输入字段要么是数值类型,要么是向量类型,普通String字段永远不行。这就是为什么“String转向量”几乎是每个Spark机器学习项目绕不开的环节。1.2 SparseVector和DenseVector,到底选哪个在动手转换之前,先弄清楚稀疏向量和稠密向量这两个概念的差异,能帮你省很多不必要的折腾。稠密向量(DenseVector)说白了就是一个普通的数组,每个位置都存一个值,没有值就用0.0补齐。例如Vectors.dense(1.0, 0.0, 3.0, 0.0),底层就是一个长度4的Double数组。这种表示方式直观、访问快,适合维度不高(比如几十到几百维)的场景,缺点就是浪费内存,如果特征维度上万但大部分是0,存一堆0实在不划算。稀疏向量(SparseVector)则只记录非零位置和对应的值,例如Vectors.sparse(4, [0, 2], [1.0, 3.0]),表示这是一个长度为4的向量,只有第0位和第2位有值。这种结构在文本特征、独热编码、高维稀疏特征场景下能省下大量内存,而且计算起来也更快,因为不需要遍历所有维度。从Spark自身的行为也能看出设计倾向:OneHotEncoder输出的就是SparseVector,因为它本质上是把某个索引位置置1、其余全是0;HashingTF输出的也是SparseVector,因为分词后的哈希表大部分位置都是空的。而你自己把一个字符串数组直接解析出来的结果,通常是DenseVector,因为每一个维度都确实有实际数值。选哪个没有绝对标准,核心就一句话:维度不高、非零值占多数就用Dense;维度高到万级别、绝大多数位置都是0,就用Sparse。2. 先分清你的String属于哪种语义2.1 数值字符串:解析成数组再转向量“数值字符串”指的是内容本身就是一堆数字,只是被塞进了String列。典型形态包括:"1.0, 2.0, 3.5""[1.0,2.0,3.5]""1.0|2.0|3.5""1 2 3.5"(空格分隔)这类字符串处理起来最简单,因为语义就是“按分隔符拆开,逐个转成Double,装进向量”,不涉及任何映射或编码。我在实际业务里见过最多的是第一种,即逗号分隔的纯数字串,有些系统导出数据时就喜欢这么玩,看起来方便但给下游留了不少活。要注意的是分隔符可能不止一种,有的数据源会混着逗号和竖线,有的会带括号,这些都得先清洗。还有一点容易被忽略:字符串里的数字可能是整数形态"1,2,3",也可能是浮点"1.0,2.5,3.0",解析成DoubleType是没问题的,但如果你用StringIndexer去处理这种列,那就完全搞错语义了,因为StringIndexer会把这些数值当成离散标签,比如把"1.0"和"2.0"当成两个不同的类别,这显然不是你想要的效果。2.2 类别字符串:需要索引和独热编码另一种更常见的场景是,字符串本身是分类标签,例如:用户的性别:"M"/"F"商品的品类:"电子"/"服饰"/"食品"城市:"北京"/"上海"/"广州"这种字符串不能直接拆成数组,因为apple跟banana之间没有数值大小关系,你不能说apple = 1.0、banana = 2.0就直接丢给模型,否则树模型可能做出一堆没意义的切分,线性模型更是会被这个伪造的排序带偏。正确的做法是先StringIndexer转成数值索引,再用OneHotEncoder做独热编码。独热编码的输出天然就是稀疏向量:每个类别对应一个位置,当前样本属于哪个类别,那个位置就是1,其余全是0。举个例子,三个城市标签"北京"、"上海"、"广州"分别索引为0.0、1.0、2.0,那么一条"北京"的记录独热编码后就变成SparseVector(3, {0: 1.0}),只存了一个位置。这里容易踩一个误区:如果分类变量的基数很低,比如只有两三个取值,独热编码是合理的;但如果基数有几百上千,比如用户ID、商品ID这种,独热编码会直接把特征维度撑爆,而且大部分位置都是0,效果和性能都很差。遇到这种高基数类别,我更推荐直接用数值索引列当作特征,或者用目标编码等方式,不要硬上独热。2.3 自由文本:分词之后哈希稀疏化第三种场景是真正的文本内容,比如评论、描述、日志文本,例如:"spark is fast and powerful""机器学习模型的特征工程很关键"这种字符串既不是数值,也不是简单的离散标签,而是需要经过分词、清洗、特征映射才能变成向量。Spark里最经典的方案是Tokenizer+HashingTF:先Tokenizer把句子切成单词列表,再用HashingTF把单词哈希到固定维度的稀疏向量里。HashingTF内部会维护一个固定大小的数组,每个单词通过哈希函数映射到一个索引位置,并统计词频,最终输出一个大部分位置为0的稀疏向量。这种方案的好处是速度快、不需要预建词典,坏处是哈希冲突不可避免,不同单词可能落到同一个桶里,但只要维度设置得足够大,冲突对模型的影响通常可控。HashingTF适合海量文本的粗粒度特征,如果追求更精细的效果,可以换CountVectorizer,它需要先扫描语料建词典,输出的是基于真实词典的稀疏向量,可解释性更强,但代价是更吃内存和时间。3. 三种可直接照抄的转换方案3.1 方法一:split + cast + array_to_vector 快速做DenseVector如果你的String是“数值字符串”,也就是用分隔符连接的Double数组形态,array_to_vector是效率最高的路子,不需要写UDF,全程走原生函数。先看完整例子:from pyspark.sql import SparkSession, functions as F from pyspark.sql.types import ArrayType, DoubleType from pyspark.ml.functions import array_to_vector spark = SparkSession.builder.master("local[*]").appName("string_to_vector").getOrCreate() df = spark.createDataFrame([ (1, "1.0,2.0,3.5"), (2, "4.0,5.0,6.0"), (3, "7.5,8.8,9.9") ], ["id", "features_str"]) df_dense = df.withColumn( "features_arr", F.split(F.col("features_str"), ",").cast(ArrayType(DoubleType())) ).withColumn( "features", array_to_vector(F.col("features_arr")) ) df_dense.printSchema() df_dense.show(truncate=False)这段代码做的事情很清晰:F.split按逗号把字符串拆成字符串数组,.cast(ArrayType(DoubleType()))把这个数组里每个元素从String变成Double,最后array_to_vector把数组列转成稠密向量列。这里每一步都有讲究,先拆后cast是有顺序的,如果直接对String列做cast,得到的结果不是我们想要的数组,而是乱码或null。如果你的Spark版本比较老,比如2.4或更早,array_to_vector还不存在,可以退而求其次自己写一个UDF:from pyspark.sql.types import ArrayType, DoubleType from pyspark.ml.linalg import Vectors, VectorUDT def array_to_vector_udf(arr): if arr is None: return None return Vectors.dense(arr) to_vec = F.udf(array_to_vector_udf, VectorUDT()) df_dense = df.withColumn( "features_arr", F.split(F.col("features_str"), ",").cast(ArrayType(DoubleType())) ).withColumn( "features", to_vec(F.col("features_arr")) )注意,这里UDF返回类型必须是VectorUDT(),否则DataFrame不知道这一列是向量类型,后边的训练器还是认不出来。我自己写这个UDF的时候曾经返回过Python的list,结果跑模型直接报Unsupported data type,非常痛苦。3.2 方法二:StringIndexer + OneHotEncoder 输出SparseVector处理“类别字符串”的完整链路是这样的:from pyspark.ml.feature import StringIndexer, OneHotEncoder df_cat = spark.createDataFrame([ (0, "apple"), (1, "banana"), (2, "apple"), (3, "cherry") ], ["id", "fruit"]) indexer = StringIndexer(inputCol="fruit", outputCol="fruit_index") df_idx = indexer.fit(df_cat).transform(df_cat) encoder = OneHotEncoder(inputCol="fruit_index", outputCol="fruit_vec") df_enc = encoder.fit(df_idx).transform(df_idx) df_enc.show(truncate=False)输出结果里fruit_vec是SparseVector类型。比如apple对应的可能是SparseVector(3, {0: 1.0})。之所以看到的是稀疏而非稠密,是因为独热编码天然稀疏:3个类别就3个位置,其中2个是0,只有1个是1,Spark就干脆只存那个非零位置。StringIndexer这里有个细节需要注意:它会按频次对类别排序,出现频率最高的类别索引为0,次高的索引为1,以此类推。比如上面例子里apple出现了两次,就会拿到索引0;banana和cherry各出现一次,分到索引1和2。如果业务上有明确顺序,建议用StringIndexer的stringOrderType参数改排序方式,或者干脆自定义映射。还有一点可能很多新手不知道:在Spark 3.3之前,这个编码器的名字叫OneHotEncoderEstimator,3.3版本之后才精简成OneHotEncoder并支持同时输入多列。如果你的集群是Spark 2.x或早期3.x,还在用旧的OneHotEncoder,会麻烦很多,因为它一次只接收一个输入列,而且不能像新版本那样直接用inputCols和outputCols传多个列名。我见过不少人在版本切换时被这个类名改动卡住,代码在网上抄了一个新的,结果部署到老集群直接报ClassNotFound或者NoSuchMethodError。3.3 方法三:Tokenizer + HashingTF 把文本转成SparseVector处理自由文本的标准流水线如下:from pyspark.ml.feature import Tokenizer, HashingTF df_text = spark.createDataFrame([ (0, "hello world spark"), (1, "spark is fast"), (2, "hello spark ml") ], ["id", "text"]) tokenizer = Tokenizer(inputCol="text", outputCol="words") df_words = tokenizer.transform(df_text) hashing = HashingTF(inputCol="words", outputCol="features", numFeatures=10000) df_hash = hashing.transform(df_words) df_hash.show(truncate=False)HashingTF默认输出SparseVector,numFeatures控制了向量的维度,这个参数非常关键。维度太小,哈希冲突率会升高,不同单词可能映射到同一个位置,导致特征信息丢失;维度太大,向量虽然依然稀疏,但数据整体体积变大,训练开销也上去。没有绝对最优值,我在业务里一般从2^15到2^18这个区间试,文本量越大,维度越要偏大一点。如果文本里数字和符号也多,建议在Tokenizer前面加一个RegexTokenizer,用正则控制切分规则,比如只保留字母、数字和中文。Spark原生的Tokenizer是基于空格和标点切分的,对中文没有任何分词能力,它对中文的处理方式是整句当成一个词。你如果做中文文本特征,千万别直接用Tokenizer,那出来的特征基本不能用。中文场景我一般借助分词库先处理好,再把切好的结果以空格分隔符写入一个临时列,然后用Tokenizer去切,或者干脆自定义一个UDF把分词结果数组直接变成词序列。3.4 自己写UDF?先看清这三条边界很多场景下,Spark内置组件覆盖不了,比如字符串格式非常特殊,或者要在一行里同时做多种逻辑转换。这时候就得自己写UDF,但写UDF有代价,得想清楚。第一,UDF的性能远低于内置函数。PySpark的UDF在大数据量下需要把JVM里的数据序列化到Python进程再传回来,这个开销非常大。我在一次处理几千万行数据时,用array_to_vector原生函数跑了几分钟,换成同样的逻辑写UDF跑了几十分钟,差距不止一点。所以能不用UDF就不用UDF,非要用的场景也尽量把UDF的逻辑压在单行计算上,不要在里面做太重的操作。第二,UDF的返回类型必须精确声明。上面例子里返回VectorUDT()是正确的,如果你返回Python list或者误把Vectors.dense和Vectors.sparse混着返回,Spark的序列化器会当场给你报错。我还见过有人写数组转向量的UDF时,函数名和变量名跟pyspark.ml.linalg.Vectors重名了,结果一调用就报'module' object is not callable,排查半天才发现是命名冲突。第三,UDF对null的处理要主动兜底。DataFrame里某个字符串列有null,UDF默认接收到的是None,你不做判断直接split会抛异常。习惯性在函数开头加一个if arr is None: return None,能避免后面很多麻烦。这一点我后面专门再展开说。4. 把向量送进模型前的最后一步4.1 VectorAssembler多列合并成特征向量实际建模很少只有一个特征,一个典型的特征向量可能由三部分组成:原始的数值列(比如年龄、收入)、独热编码后的稀疏向量列(比如城市)、哈希处理后的文本特征向量列。Spark不会自动帮你把这三种列拼到一起,需要VectorAssembler出手:from pyspark.ml.feature import VectorAssembler assembler = VectorAssembler( inputCols=["age", "income", "fruit_vec", "text_features"], outputCol="final_features" ) final_df = assembler.transform(df_enc_with_text)VectorAssembler要求所有inputCols都是数值类型或向量类型,任何一个普通String列混进来都会直接报错。这也是前面为什么非要把String转成向量的核心原因之一:就算VectorAssembler能容忍其他类型,它也不会帮你把字符串自动转换。使用VectorAssembler还有一个隐性规则:向量列的维度会被自动拼接。比如fruit_vec维度是3,text_features维度是10000,拼出来的final_features维度就是3加10000再加两个数值特征的维数。你不需要手动计算总维度,VectorAssembler内部会处理,这在后面做模型解释或者系数反解时反而有帮助,因为你始终知道每个区块对应哪个原始特征。4.2 完整Pipeline示例:从原始String到可直接训练的DataFrame把前面几种方案组合起来,一条完整的链路是这样的:from pyspark.ml import Pipeline from pyspark.ml.feature import StringIndexer, OneHotEncoder, Tokenizer, HashingTF, VectorAssembler raw_df = spark.createDataFrame([ (1, "hello world", "apple", 28, 10000), (2, "spark is fast", "banana", 35, 25000), (3, "hello spark", "apple", 40, 30000) ], ["id", "text", "city", "age", "income"]) tokenizer = Tokenizer(inputCol="text", outputCol="words") hashing = HashingTF(inputCol="words", outputCol="text_features", numFeatures=100) city_indexer = StringIndexer(inputCol="city", outputCol="city_index") city_encoder = OneHotEncoder(inputCol="city_index", outputCol="city_vec") assembler = VectorAssembler( inputCols=["age", "income", "city_vec", "text_features"], outputCol="features" ) pipeline = Pipeline(stages=[tokenizer, hashing, city_indexer, city_encoder, assembler]) model = pipeline.fit(raw_df) result_df = model.transform(raw_df) result_df.select("id", "features").show(truncate=False)这一步跑通了,features列就是一个标准的VectorUDT列,直接可以传给LogisticRegression、RandomForestClassifier等模型训练器。用Pipeline的好处不只是代码规范,更重要的是把特征转换过程固化成一套可复用的流程,模型上线时用同一个Pipeline处理实时数据,不会出现训练和预测特征口径不一致的问题。我在实际业务中非常推荐把特征工程全链路Pipeline化,尤其是涉及到StringIndexer这类有状态组件的时候。StringIndexer在训练时学到的标签映射必须在下游预测时复用,你如果训练用Pipeline.fit生成模型,预测时直接调用同一个模型对象,麻烦最少。如果手工拆开各个Transformer,稍不留神预测时少跑一步或者重新fit了一遍,特征顺序就变了,模型预测结果直接废掉。5. 我踩过的坑:排查记录与经验笔记5.1 null和空字符串:看起来一样,踩进去完全不同字符串数据里最让人头疼的两种“脏值”就是null和空字符串。看起来差不多,但在Spark的行为上差异巨大。F.split(null, ",")的结果是null,不是空数组;F.split("", ",")的结果是[""],一个包含单个空字符串的数组。这两种情况转向量时表现完全不同:null变成null向量没问题,后面训练器会把它当成缺失值处理;[""]再cast成ArrayType(DoubleType()),数组里那个空字符串会转成null,最后array_to_vector遇到数组里有null元素,整个转换直接失败,报错往往是cannot convert array element null to vector。解决的思路很朴素,转换前先清洗:df_clean = df.withColumn( "features_str", F.when(F.trim(F.col("features_str")) == "", None).otherwise(F.col("features_str")) )把空字符串统一先转成null,后面所有处理都简单得多。写UDF的人更要注意,函数入口就对null做判断。我建议把这个习惯刻进脑子里,凡是写接收DataFrame列值的UDF,第一行永远是null判断,不要问为什么,问就是踩过坑。5.2 Spark版本不同导致“找不到类”这个坑我从2.4一直踩到3.4,印象太深了。最典型的例子就是前面说的OneHotEncoderEstimator和OneHotEncoder。假设你在网上找了一份新版本代码,直接拿到一个Spark 2.4的集群上跑,OneHotEncoder(inputCols=[...])这个构造方式根本不存在,要么报类不存在,要么报参数不一致。另一个版本差异是array_to_vector和vector_to_array这两个函数,它们是Spark 3.0引入的。如果你维护的脚本可能在不同Spark版本之间切换,建议写个检测逻辑,或者干脆统一用UDF方案,牺牲一点性能换兼容性。更隐蔽的版本差异藏在StringIndexer的参数上。早期版本的StringIndexer.setHandleInvalid("keep")可能不生效或者行为不同,导致预测时遇到训练集没见过的类别,直接把它映射到某个伪索引。这个坑在模型上线后如果新数据类别漂移就会突然爆发,表现为模型效果莫名其妙变差,但代码看起来毫无问题。5.3 解析精度和分隔符的坑字符串拆成Double的过程看着简单,实际坑也不少。最常见的是分隔符不一致,同一列数据里既有英文逗号也有中文逗号,或者某些行用竖线。我建议在split之前统一做一次字符串规整,比如把全角逗号替换成半角,再把多种分隔符统一成一种。步骤多两步,但省下的排查时间可不止两步。精度问题也很隐蔽。字符串里的数字可能是"0.1",但在Double里存的是0.1000000000000000055511151231257827,转成向量后数值本身会变化。如果你后续做的是比较严格的数值计算(比如算距离),这种精度误差虽然小,但积累起来会让人抓狂。出现极高精度要求时,可以考虑保留Decimal类型先做运算再转Double,或者直接用高精度库处理完再入向量。5.4 性能取舍:UDF、原生函数与序列化开销前面提过UDF和大数据量下的性能问题,这里再具体说说。字符串转向量这个操作,如果能用split加cast加array_to_vector原生组合,大部分情况下是够快的。但如果你的字符串格式极其诡异,必须用UDF,性能优化可以从两个方向下手:一是用pandas_udf批量处理,减少单行序列化次数;二是尽量把多个UDF合并成一个,避免同一行数据在Python和JVM之间反复横跳。我这边的实操经验是:几百万行以下,单写UDF问题不大;几千万行以上,UDF耗时很容易比模型训练都长,不优化基本没法用。优先让数据工程师在上游把字符串解析成数组,或者以结构化格式落盘,把脏活累活在上游干完,下游特征工程只负责轻量转换,这是对整个链路最友好的方案。6. 最后再分享一点实操体会做了这么多年特征工程,我越来越觉得字符串转向量这件事,本质上是在回答一个问题:你手里的字符串到底凭什么代表这个特征?是数值序列、离散标签,还是非结构化文本?三种语义对应三种完全不同的处理路径,选错一条,后面怎么调都别扭。我的建议是,在开始写代码之前,先花十分钟把你手头的String列都梳理一遍,按语义分类标注好。这一步省下的时间,远超过爬各种报错的时间。真的碰到字符串格式极其混乱的情况,也别硬撑,直接看能不能在上游数据导出时就改成结构化格式,比如多值用数的顺序和位置表达,分类用单独的整数编码,文本先做分词处理。上游多走一步,下游少跑十次排查。如果非要在本文最后留一句个人经验,那就是:能走原生函数就绝对不写UDF,能在Pipeline里固化流程就绝对不手工拼凑,能把空值统一成null就绝对不让它在转换中途冒头。这三条守好了,你的字符串转向量之路会顺畅非常多。