资讯详情

资讯详情

矩阵的秩怎么算:从手算消元到数值秩与SVD排错

前两天帮一个做数据建模的朋友看代码他截了张图问我同一个矩阵为什么他本地跑出来秩是 5放到服务器上跑出来却是 6我把矩阵调出来扫了一眼就明白了——里面有一列是另外两列的线性组合只是因为数据在导出时做过四舍五入理论上严格为零的那个位置实际存的是 1e-9 量级的残差。这就是典型的数值秩问题。计算矩阵的秩这件事听起来是线性代数课本第一章的内容一行一行消元、数一数主元就完事了可一旦把它搬到计算机上、搬到真实数据上这个整数就开始变得模糊。不是数学定义有问题而是浮点表示、消元顺序和阈值判定在一起搅局。我写这篇东西是想把算秩这件小事从黑板拉到工位上。它解决的问题非常具体判断一组向量到底有几个是独立的、判断一个线性方程组是有唯一解还是无穷多解还是无解、判断一批特征里有多少是冗余的、判断一个系统模型是不是能控能观。做数据分析的、做数值计算的、做控制工程的、做图像压缩的甚至只是想把线性代数作业写对的学生都会用到。文章里既有手算的完整推导也有可以直接复制运行的代码还有一堆我在实际项目里踩过的坑——尤其是最后那部分关于同一个矩阵算出两个答案的排查记录如果你正在被类似问题困扰那几段值得反复看。1. 矩阵的秩到底在算什么先把它理解成有效信息量1.1 一堆问卷里到底有几个独立问题我喜欢用问卷来类比。假设你收集了一份问卷上面有 200 个问题但你仔细一看第 3 题是第 1 题和第 2 题的加和第 50 题是第 3 题乘以 2第 120 题又是前面某几题的重新组合。那么这 200 个问题里真正携带独立信息的可能只有 20 个——剩下的都是这 20 个的衍生品。矩阵的秩说的就是这个真正独立的行或列数量。把矩阵看成一组行向量排在一起秩就是这组向量张成的空间的维数。如果第 3 行能被第 1、2 行线性表示出来那它就没贡献新的维度秩不会因为它的存在而增加。这一点很关键秩刻画的是整个向量组的表达能力上限而不是元素个数。一个 1000×1000 的矩阵秩可能只有 3意味着它看上去装了一百万个数实际上所有信息都压缩在三个方向的组合里。反过来一个 3×1000 的矩阵秩最多只有 3因为行数摆在那儿——秩永远不超过行数和列数中的较小者。理解了这一层很多结论就变得非常自然秩为 n 的 n 阶方阵必然可逆每一行都贡献了新维度没有冗余秩小于 n 就不可逆存在冗余存在非零向量被映射到零。所谓奇异矩阵本质上就是信息有重复、维度塌陷的矩阵。这个直觉在后面的算法选择里会反复用到。1.2 三个等价定义对应三条算法路线教科书上通常给出三种等价的定义我在实际工作中发现每个定义都直接对应一种实现路径理解它们的差异比记住定义本身更重要。第一种是行阶梯形定义把矩阵做初等行变换化成阶梯形非零行的数量就是秩。这条路线对应高斯消元法是手算的标准流程也是大多数教学场景采用的路径。第二种是行列式定义针对方阵n 阶方阵的秩等于它所有非零子式的最高阶数。换个说法秩 r 意味着存在一个 r 阶子式不为零而所有 r1 阶子式都为零。这条路线在理论上很干净但计算上极其糟糕——一个 10 阶矩阵要检查的子式数量是天文数字只能用于极小规模或者理论证明。第三种是奇异值定义矩阵的秩等于它非零奇异值的个数。这条路线对应奇异值分解SVD是数值计算中最稳健的做法。它的好处是把是否为零这个问题从原始元素层面转移到了奇异值层面而奇异值对扰动是有序的、可量化的这就让数值秩的判定有了依据。我在选型时的基本原则是手算和教学用手法一理论推导用手法二工程代码用手法三。而实际写代码时稍微大一点的矩阵一律走 SVD除非有明确的性能约束或其他特殊需求。原因在第四节会详细讲。1.3 秩亏意味着什么从解方程的角度看秩的另一个高频用法是判断线性方程组 Ax b 的解的结构。设 A 是 m×n 矩阵b 是 m 维向量那么若 rank(A) rank([A|b]) n方程有唯一解若 rank(A) rank([A|b]) n方程有无穷多解自由度为 n - rank(A)若 rank(A) rank([A|b])方程无解。这里 rank([A|b]) 是把 b 作为增广列拼进去之后的秩。这个判据看起来简单但里面藏着一个很实用的观察增广之后秩增加了说明 b 引入了新的维度也就是 b 落在了 A 的列空间之外。工程上碰到最小二乘解残差特别大的情况八成就是这个原因——你的模型根本表达不了这批数据不是参数没调好是模型结构不对。我自己在做一个传感器标定的小工具时就吃过这个亏。当时用一组标定数据去解方程残差一直很大我以为是噪声问题反复清洗数据都没用。后来算了增广矩阵的秩才发现理论上 rank(A) 应该是 3实际算出来是 4 和 3 之间反复横跳——增广列确实带来了微小但稳定的新方向。原因是一个传感器的量程被设错了导致数据本身违反了物理约束。秩在这里起到的作用相当于一个结构体检比看残差曲线直观得多。2. 手算路线初等行变换、主元与消元计数2.1 行阶梯形不是唯一的但秩是唯一的初等行变换有三种交换两行、某行乘以非零常数、某行加上另一行的倍数。这三种操作都不改变矩阵的秩这是整个消元法的理论基础。但要注意一个细节化简得到的行阶梯形不唯一不同的人用不同的消元顺序会得到不同的阶梯形但非零行的数量一定相同。这个不唯一但秩唯一的性质恰好也是后面数值算法里结果可以浮动但不应大幅浮动的伏笔。判断阶梯形的标准有三条所有非零行都在全零行之上每行第一个非零元素主元所在的列比上一行主元所在的列更靠右主元下方全为零。满足这三条之后数一数有几个非零行秩就出来了。我在教别人手算时最常提醒的一点是不要死盯着对角线。很多教材的例子都是方阵主元刚好在对角线上容易形成主元一定在对角线的错误直觉。一旦碰上非方阵或者中间出现零主元需要换行这个直觉就会崩。正确的习惯是始终记住主元是当前行最左边的非零元素位置随行而变不固定在对角线上。2.2 手算一个 3×4 矩阵把每一步摊开拿一个具体的例子走一遍比看定义管用得多。设A [ 1 2 3 4 ] [ 2 4 6 9 ] [ 3 6 9 13]第一步用第一行消去下面两行的第一列。第二行减去第一行的 2 倍得 [0, 0, 0, 1]第三行减去第一行的 3 倍得 [0, 0, 0, 1]。此时矩阵变为[ 1 2 3 4 ] [ 0 0 0 1 ] [ 0 0 0 1 ]第二步用第二行消去第三行。第三行减去第二行得 [0, 0, 0, 0]。矩阵变为[ 1 2 3 4 ] [ 0 0 0 1 ] [ 0 0 0 0 ]现在数非零行是 2 个所以 rank(A) 2。这个例子里有两个观察点值得停一下。第一第二列和第三列虽然看着有数值但它们都是第一列的倍数关系第 2 列 2×第 1 列第 3 列 3×第 1 列所以在消元过程中整个列都被清空了——这直观地展示了线性相关列的贡献会被抵消。第二第四列的第一行是 4第二行是 9第三行是 13差值分别是 5 和 4不成比例所以它贡献了独立的维度。最终秩为 2意味着这个 3×4 矩阵的所有列都落在由两个方向张成的平面里。再看一个稍微绕一点的例子专门用来演示换行。设B [ 0 1 2 ] [ 1 2 3 ] [ 2 4 6 ]第一列第一个元素是 0不能做主元先交换第一、二行[ 1 2 3 ] [ 0 1 2 ] [ 2 4 6 ]用第一行消第三行第三行减第一行的 2 倍得 [0, 0, 0]。再用第二行消得[ 1 2 3 ] [ 0 1 2 ] [ 0 0 0 ]秩为 2。这里如果一开始不做换行直接拿 0 去当主元除整个流程就崩了。这就是部分主元法的雏形选主元时优先挑绝对值大的或者至少保证不为零。这个习惯在纸上算的时候显得多余在代码里就是生死攸关的一条纪律。2.3 分数爆炸问题与整数保底技巧手算矩阵最容易翻车的地方是分数。上面两个例子碰巧都很干净一旦碰到主元是 3、而下面某行是 5 的情况分数就开始滚雪球算到后面自己都不信自己的结果。我常用的两个对策第一个是整数保底法。在每一步消元之前观察目标行的系数尝试用行变换把分数避开。比如要消去 (2,1) 位置的 5主元是 3正常做法是第二行减去第一行的 5/3 倍但你可以先把第二行乘以 3再做减法这样就全程在整数域内操作。行乘以非零常数不改变秩所以这个操作完全合法。代价是数值会变大但手算时数字大一点总比分数链好核对。第二个是分层记录法。把每一步的矩阵写在单独一行并且用箭头标注r2 - 2r1这样的操作说明。听起来很啰嗦但当你在第 5 步发现结果不对劲需要回溯时有操作记录的草稿能救命。我自己算 4 阶以上的矩阵习惯把每一步的变换写在右侧空白处最后检查时从后往前逆推一遍能抓出大部分算术错误。提示手算时如果某一步出现了非整数不要硬着头皮往下算先回头看看是不是可以用先乘后减的方式把分数消掉。分数链一旦超过两层出错概率会陡增。还有一个容易被忽略的点行变换虽然不改变秩但会改变行列式的值。如果同一个矩阵你既要算秩又要算行列式别混在一起做——算秩的过程中一旦用了某行减去另一行的倍数行列式不变但如果你用了交换两行行列式变号如果用某行乘 k行列式乘 k。秩不受影响行列式可就面目全非了。这个坑我在给学生改作业时见过太多次两个问题混在一张草稿纸上做最后两个答案都错。3. 代码路线从一行调用到自己实现3.1 现成函数怎么用才不出错绝大多数场景下你不需要自己写消元。科学计算生态里已经有成熟的秩计算函数Python 里最常见的是numpy.linalg.matrix_rank。它的用法极简import numpy as np A np.array([[1, 2, 3, 4], [2, 4, 6, 9], [3, 6, 9, 13]], dtypefloat) r np.linalg.matrix_rank(A) print(r) # 2但这行调用里藏着一个关键参数tol容差。如果不显式指定函数会根据矩阵的最大奇异值和机器精度自动推算一个默认阈值大致形式是S.max() * max(M, N) * eps其中S.max()是最大奇异值M、N是矩阵维度eps是浮点机器精度双精度约 2.22e-16。这个默认值在大多数情况下是合理的但它有一个隐含假设矩阵的各个方向量级大致相当。一旦你的数据里存在量纲差异巨大的列——比如一列是电压量级 1一列是电阻量级 1e6——这个默认阈值就可能把本该保留的维度判成零或者把本该丢掉的噪声判成有效维度。我处理这类数据时的标准动作是先做列归一化再算秩def rank_normalized(A): A np.asarray(A, dtypefloat) norms np.linalg.norm(A, axis0) norms[norms 0] 1.0 return np.linalg.matrix_rank(A / norms)归一化之后每一列的范数都是 1量级被拉平默认阈值的假设就成立了。这是一个非常小但极其实用的改动我在做传感器数据融合时基本是标配。3.2 自己写一版带部分主元的消元有些场景必须自己实现比如需要在嵌入式环境里跑、需要中间过程的副产物、或者需要控制每次消元的顺序。下面是我常用的一个版本带部分主元选择partial pivoting和显式容差def rank_gauss(A, tol1e-10): 用带部分主元的高斯消元计算矩阵的秩 A np.array(A, dtypefloat, copyTrue) m, n A.shape rank 0 for col in range(n): # 在当前列中找绝对值最大的行作为主元 pivot_row rank np.argmax(np.abs(A[rank:, col])) if abs(A[pivot_row, col]) tol: continue # 这一列在主元行以下全为零跳过 if pivot_row ! rank: A[[rank, pivot_row]] A[[pivot_row, rank]] # 用主元行消去下方所有行 factors A[rank 1:, col] / A[rank, col] A[rank 1:, col:] - factors[:, None] * A[rank, col:] rank 1 if rank m: break return rank这段代码有几个设计决定值得解释。第一外层循环遍历的是列而不是行。这是处理非方阵的关键如果你按行循环一旦遇到某行的主元位置已经被前面的列占满逻辑就会混乱。按列遍历、用一个独立的rank变量记录当前已经找到的主元数逻辑上天然正确而且能自动处理某列全零的情况。第二pivot_row的搜索范围是A[rank:, col]即从当前主元行往下找而不是整列找。这保证了已经处理好的主元行不会被破坏。第三也是最重要的一点判断阈值用的是绝对的tol而不是相对阈值。这在做演示时没问题但用在真实数据上是有风险的具体原因在下一节展开。我实测过这个函数和np.linalg.matrix_rank在随机矩阵上的一致性1000 次随机测试维度 2 到 20元素服从标准正态分布中两者的结果完全一致。但在构造的病态矩阵上差异就出来了这正好引出下一节的讨论。3.3 大矩阵与稀疏矩阵的现实取舍规模上去之后策略要变。几百乘几百的稠密矩阵SVD 的复杂度是 O(mn·min(m,n))在现代机器上还是毫秒级的随便算。上千维之后如果你的矩阵是稠密的SVD 可能就要几秒到几十秒这时候可以考虑几个替代方案。第一个方案是用随机化 SVD。它的思路是先做一个低维投影把大矩阵压到远小于原尺寸的子空间里在子空间上做精确 SVD再映射回来。对于秩远小于维度的矩阵这个方法的精度损失很小速度提升却可以是数量级的。第二个方案是走QR 分解。带列主元的 QR 分解能把矩阵按信息量从大到小排序然后看 R 矩阵对角线上的元素从大到小数到某个阈值为止那个位置就是数值秩。这个方法在只关心秩、不关心奇异值本身时比完整 SVD 更快。第三个方案适合超大稀疏矩阵用迭代法估计比如基于随机向量的迹估计。这类方法的思路是不精确算出所有奇异值而是通过若干次矩阵-向量乘法去逼近有多少个显著奇异值。精度不如 SVD但代价极低在推荐系统、图计算这类场景里很常用。选型时我的经验是矩阵能整体装进内存、维度不超过几千直接用 SVD 别犹豫超过这个规模、且矩阵稀疏优先考虑迭代估计介于两者之间的QR 带列主元是个不错的折中。没必要为了性能过早优化绝大多数业务场景的数据规模根本到不了需要考虑这一步的量级。4. 数值秩为什么同一个矩阵会算出两个答案4.1 奇异值、条件数与误差放大回到开头那个问题为什么同一个矩阵在两台机器上算出不同的秩答案通常在奇异值的分布上。矩阵的奇异值是一组非负数按从大到小排列。理论上秩就是非零奇异值的个数但计算机里的浮点数无法表示无限接近零但不等于零的数任何一次运算都会引入相对误差量级在 1e-16 左右。当某个奇异值本身就接近这个量级时它究竟被算成零还是非零就取决于具体实现、求和顺序、甚至编译器的优化选项。更麻烦的是病态问题。矩阵的条件数定义为最大奇异值与最小非零奇异值之比。条件数是 1e12 的矩阵意味着输入数据里 1e-12 的相对扰动在输出端会被放大到 1 的量级。这种放大作用会直接污染最小那几个奇异值让它们变得完全不可信。我处理过一个协方差矩阵条件数接近 1e15用不同库算出来的秩能差三四个这时候真实秩是多少这个问题本身已经没有意义了——数据里根本不存在那么多有效信息。一个实用的判断方式是画出奇异值的衰减曲线。如果曲线在某个位置出现明显的断崖断崖之前的数量就是秩如果曲线是平缓衰减、逐渐没入噪声底那就不存在清晰的秩只能给定一个阈值说在这个精度下认为秩是多少。我习惯的做法是把奇异值和机器精度乘上最大奇异值的那个阈值画在同一张图上看它们在哪里交叉。4.2 三种方法横向对比把常用的几种方法放在一起差异就很清楚了方法计算复杂度数值稳定性适用场景主要风险行列式子式法指数级理论上精确极小规模、理论推导规模稍大就完全不可算高斯消元计数O(mn·min(m,n))中等依赖主元策略手算、教学、小规模稠密无主元选择时误差累积严重QR 带列主元O(mn·min(m,n))较好中等规模、需要排序阈值选择敏感完整 SVDO(mn·min(m,n)) 但常数大最好奇异值有序工程默认选择大矩阵上耗时明显随机化方法与目标秩相关中等到较好超大规模、低秩结构需要预估秩精度有损这张表里我想强调一点复杂度的理论值看起来差不多常数差异却很大。高斯消元和 QR 在同一量级但 SVD 的常数可能是前者的五到十倍。所以能不能用消元代替 SVD这个问题答案取决于你的数据有多干净。数据干净、量级统一消元完全够用数据脏、量级混乱省下来的那点时间还不够你排查一个错误结果的。4.3 阈值到底该定多少这是被问得最多的问题也是最没有标准答案的问题。我的思路是分三步走。第一步先看奇异值的绝对量级分布。把奇异值打印出来看最大值和最小值差多少个数量级。如果差了 15 个数量级以上基本可以确定最小那几个是数值噪声不用纠结。第二步用相对阈值而不是绝对阈值。最常用的形式是tol max(m, n) * eps * S_max其中eps是机器精度S_max是最大奇异值。这个形式的理由是误差在消元过程中最多累积到与维度和机器精度成正比的量级所以阈值也应该按这个量级设。这也是主流库默认采用的形式。第三步在业务层面确认这个阈值是否合理。这一步最容易被跳过但它往往才是关键。举个具体的例子如果你的矩阵来自一组物理测量而测量设备的精度只有三位有效数字那么任何小于最大奇异值 1e-3 倍的奇异值在物理上都不应该被当作有效信息——不是算法算不准是你的数据根本支撑不起这个精度。这时候应该把阈值设在 1e-3 的相对量级而不是死守机器精度的默认值。我在一个图像处理的场景里就把这个思路用得很实对图像块做低秩分解时用机器精度阈值算出来的秩几乎是满秩的因为图像里有大量微小噪声改成按视觉可辨的误差容限来设阈值之后秩降到了原来的三分之一压缩比上去了画面肉眼几乎看不出差别。阈值不是一个数学参数它是一个业务参数这句话我常跟团队里的人讲。5. 排错实录与常见问题速查5.1 问题速查表现象可能原因排查动作处理方式同一矩阵不同环境算出不同秩奇异值处于阈值附近打印全部奇异值看最小的几个的量级显式指定 tol或做归一化秩等于 min(m, n)且数据是测量值未归一化或噪声过大检查各列量级是否悬殊列归一化后再算秩远小于预期阈值过大或存在近共线列看奇异值衰减曲线调小相对阈值检查共线来源手算结果比代码小消元时误把相关行当独立行重做一次标注每步变换使用整数保底法重算方阵满秩但求逆报错条件数过大数值上接近奇异算条件数改用伪逆或正则化消元过程出现 NaN主元为零时做了除法检查是否启用部分主元加入主元选择逻辑稀疏矩阵算出满秩零元素被浮点噪声填充检查稀疏结构是否被破坏使用稀疏专用算法5.2 几个印象比较深的坑第一个坑是关于数据类型的。有一次我用一个整数类型的数组去算秩结果一切正常直到某天数据里混进了一个小数整个数组被静默转成了浮点然后秩就变了。原因是整数运算和浮点运算的消元路径完全不同整数相减不会产生舍入误差而浮点会。这不是说整数更好——整数会溢出而且遇到真正的分数就无解。我的做法是一律在入口处显式astype(float)让整个流程的数值行为统一可预期而不是依赖隐式转换。第二个坑是关于行列顺序的。有一次做特征筛选我把特征按字母序排了一遍算出来的秩和按原始顺序算的不一样。听起来很离谱但原因很朴素不同的列顺序会导致消元时主元的选择不同误差累积路径也就不同在病态矩阵上就表现为秩的差异。这个现象提醒我对于条件数大的矩阵秩这个数值本身就不该被当作一个绝对可信的整数而应该当作一个带不确定度的估计。后来我的做法是对同一矩阵随机打乱列顺序算十次如果结果不稳定就说明数据本身有问题先回去清洗数据而不是纠结于哪个数字是对的。第三个坑是关于内存布局的。这个坑比较隐蔽用 C 顺序存储和用 Fortran 顺序存储的同一个矩阵在某些底层实现里计算路径不同累加求和的顺序不同最后的奇异值会有末位差异。绝大多数时候这个差异不影响秩但当奇异值正好卡在阈值上的时候它就足以翻转结果。我的应对方式很土但有效永远不要在阈值边缘做判断。如果算出来的最小有效奇异值和阈值只差一两个数量级那就说明这个维度本身就是可疑的与其纠结算成几不如把它标记为不确定在后续处理里单独对待。提示如果你在线上系统里做秩判定务必把阈值和奇异值分布一起打日志。只看一个整数结果出问题时完全无从排查。这是我花了一个通宵才换来的教训。6. 秩在真实场景里怎么用几个具体落点6.1 多重共线性检测做回归建模时如果设计矩阵 X 的列之间存在近似的线性关系参数估计就会不稳定系数的符号甚至可能和常识相反。判断方法之一就是算 X 的秩如果 rank(X) 列数说明存在严格共线如果秩等于列数但条件数很大说明存在近似共线同样有风险。实操上我一般会组合使用先算秩做粗筛再算条件数做细判最后看方差膨胀因子定位到具体的列。秩的作用是给出一个有没有问题的快速信号成本极低适合放在数据检查流程的最前面。一旦秩报警再去做更细致的诊断比一上来就跑全套共线性分析要高效得多。6.2 最小二乘与伪逆当方程 Ax b 无解时我们退而求其次找最小二乘解当 A 秩亏、有无穷多解时我们想找范数最小的那个。这两个需求都可以用伪逆统一处理而伪逆的构造直接依赖秩把奇异值分解 A UΣVᵀ 之后把 Σ 里的非零奇异值取倒数、零奇异值保持为零再乘回去就是伪逆。这里的零怎么判又回到了阈值问题。实践中我习惯显式设一个相对阈值把小于tol * S_max的奇异值直接置零而不是依赖库的默认行为。这样做的好处是行为可复现——换台机器、换个库版本结果不会突然变化。对于需要长期维护的代码这种确定性比省一行代码重要得多。6.3 低秩近似与数据压缩图像、推荐系统的评分矩阵、文本的共现矩阵都存在明显的低秩结构。所谓低秩近似就是只保留前 k 个奇异值及其对应的奇异向量把剩下的丢掉。k 取多少本质上就是在问这个矩阵在某个精度下的秩是多少。我在处理一批图像块时用过这个思路对每个块做 SVD按能量占比保留前 k 个分量k 由保留 99% 的奇异值平方和这个准则自动确定。实测下来纹理简单的区域 k 只有三到五纹理复杂的区域 k 能到二十以上。这个自适应的 k 值分布本身就是一个很有用的特征——它比人工设计的纹理描述子更直接地反映了局部的复杂度。6.4 系统分析中的秩判据在动态系统的分析里能控性和能观性都有基于秩的判据构造能控性矩阵如果它的秩等于状态维数系统就是能控的。这个判据在理论上是精确的但在数值上同样是阈值问题。工程上我一般不会只看秩而是同时计算能控性矩阵的奇异值用最小奇异值与最大奇异值的比值作为能控程度的度量。比值接近 1 说明控制得很轻松比值小到 1e-8 以下基本就等于不可控了中间地带则需要结合实际控制精度来判断。这个做法背后的思路其实和前面反复强调的一样秩是一个二值化的结论而现实世界是连续渐变的。与其纠结一个整数是 3 还是 4不如去看那个决定它是 3 还是 4 的量到底有多大、离边界有多远。这个习惯一旦养成你会发现很多算法不稳定的问题本质上都是问题本身处在边界上。最后分享一个我自己常用的小工具函数把奇异值、阈值、秩三样东西一次性打出来排查问题的时候特别顺手def rank_report(A, rel_tol1e-10): A np.asarray(A, dtypefloat) S np.linalg.svd(A, compute_uvFalse) tol rel_tol * S[0] if S.size else 0.0 rank int(np.sum(S tol)) print(fshape{A.shape}, rank{rank}, tol{tol:.3e}) print(fcond{S[0]/S[-1]:.3e} if S[-1] 0 else condinf) for i, s in enumerate(S): flag keep if s tol else drop print(f s[{i}] {s:.6e} {flag}) return rank每次结果不对劲跑一遍这个函数看看到底是哪个奇异值在阈值附近晃问题基本就定位了一半。剩下的那一半通常得回去看数据本身——这是我这些年算下来最深的一个体会秩算不准的时候十次里有八次不是算法的问题是数据的问题。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →