☰
MATLAB条件判断与逻辑索引:从k>=0到白马股筛选
2026/10/31 19:11:47 网站建设 项目流程

有人在技术群里甩出一句话:MATLAB 里 k 大于等于 0 到底该怎么写?底下跟了一堆答案,从k >= 0到ge(k,0),再到assume(k >= 0)、lb = 0,每种写法都对,但每种写法适用的场景完全不同。这个问题看着像新手提问,实际上它背后藏着一条很实用的主线:怎样把一个"条件"准确地翻译成机器能执行的判断,再用这个判断去筛东西。巧的是,同一周我在整理一套基本面筛选脚本,主题是白马股的初筛——你会发现这两件事在内核上是同一件事:MATLAB 里k >= 0返回的不是一句"是的",而是一个逻辑值,用来当筛子;挑白马股也一样,把"好公司"这种模糊印象拆成一组可比较的数值条件,让>=和<=去干活。这篇就把这两条线并到一起讲:前面把"k 大于等于 0"的各种表示方式、边界情况和翻车点说透,后面把这套条件表达直接落成一个能跑的白马股筛选脚本,中间穿插我踩过的坑。适合刚上手 MATLAB、又想把代码用到实际数据分析里的朋友,也适合写了很多年脚本但一直被浮点比较、逻辑索引坑过的人。

1. 一个被问烂的问题:k >= 0 的四种表示与它们各自的战场

先给结论,再讲为什么。MATLAB 里表达"k 大于等于 0"至少有四条路:关系运算符k >= 0、函数形式ge(k,0)、符号假设assume(k >= 0)、优化约束里的边界lb = 0。四条路都合法,选错的那条不会报错,只会悄悄给你一个错的结果——这才是最讨厌的地方。下面挨个拆。

1.1 关系运算符返回的是 logical,不是"真"

很多人第一次被 MATLAB 的逻辑值搞懵,是因为它在命令窗口里显示成1,看起来像个数字。实际上k >= 0的结果类型是logical,只有true和false两个取值,只不过显示的时候打成了 1 和 0。你可以自己验一下:

k = 5; r = k >= 0; class(r) % logical islogical(r) % 1 double(r) % 1,可以转成数值参与算术

这个设计的意义在于:逻辑值可以直接当索引、可以求和、可以做掩码运算。比如sum(x >= 0)得到的是"非负元素有几个",mean(x >= 0)得到的是"非负元素占比"。这两个用法在数据清洗阶段出镜率极高,比写循环数数快一个数量级。我在做缺失值统计时经常这么干,一行mean(isnan(T.roe))就能看出某个字段的缺失比例。

顺便提一句ge。ge(k,0)和k >= 0完全等价,ge就是 greater-or-equal 的缩写,同族的还有gt、lt、le、eq、ne。平时写代码没人用函数形式,它的真正价值在于两点:一是可以配合feval或函数句柄做批处理,二是当你要在字符串里拼条件、或者把运算符当参数传进另一个函数时,函数形式更顺手。我在写规则引擎的时候就吃过这个甜头——把规则表里的运算符列成"ge",用str2func转成句柄,加一条新规则不用改主循环。

还有一个容易被忽略的细节:MATLAB 里没有=>。有人从别的语言迁移过来,手滑写成k => 0,报错信息指向的位置还经常看不明白。另外>=中间不能有空格,k > = 0是语法错误。这类问题看着低级,但在赶进度的时候真的会卡住人。

1.2 k 是符号变量时,>= 不是判断而是假设

如果k是用syms声明的符号变量,那k >= 0返回的还是一个符号关系式,而不是true。这时候它的作用是"声明"而不是"求值":

syms k real assume(k >= 0) assumptions(k) % 返回 k >= 0,说明假设已生效 isAlways(k >= 0) % 返回 logical 1

这里的坑在于assume是覆盖式的。如果你先assume(k >= 0),后面又写了assume(k < 10),前面那条非负假设就被顶掉了,k >= 0不再是已知条件。要叠加条件必须用assumeAlso:

assume(k >= 0) assumeAlso(k < 10) assumptions(k) % 两条都在

带约束求解的时候,ReturnConditions参数会返回一个纯符号条件,你还得自己把它翻译回可读的结论:

syms x real sol = solve(x^2 == 4, x, 'ReturnConditions', true); sol.x % 两个根 sol.conditions % 条件表达式

而assume(k, 'real')和assume(k >= 0)是两件事:前者只排除复数,后者才限定非负。做符号推导时如果忘了声明real,sqrt(k^2)不会简化成k而是abs(k),很多人以为是软件不聪明,其实是自己没给够信息。我的习惯是符号变量一声明就把real加上,再按需叠加非负、整数这些限定,省得后面一堆化简做不动。

1.3 数组场景下,逻辑索引才是真正的主战场

实际工作中k很少是标量,多半是一整列数据。这时k >= 0返回的是一个同尺寸的逻辑数组,也就是"筛子"本身:

x = [-3 -1 0 2 5]; mask = x >= 0; % [0 0 1 1 1],logical x(mask) % [0 2 5] x(x >= 0) % 一步到位,等价写法 nnz(mask) % 3,非零元素个数

这套机制之所以好用,是因为它天然支持复合条件。多个条件用&、|、~拼起来,得到的是一个更细的筛子,而不是一串嵌套if。比如"非负且为偶数"写成x >= 0 & mod(x,2) == 0,一行搞定,不用循环。数据量上到几十万行的时候,这个差别不是风格问题而是能不能跑完的问题。

find和逻辑索引的区别也值得说一句。find(x >= 0)返回的是位置下标,适合需要"第几个"的场景;逻辑索引返回的是值本身,适合直接取值。早期代码里常见x(find(x>=0))这种写法,功能上没错,但多绕了一步,现在编辑器会提示可以简化。另外find带两个输出参数能一次拿到行列号,处理二维表的时候很方便。

2. 条件写对了却结果不对:几个高频翻车现场

语法过关只是第一步。我在帮人看代码时发现,绝大多数"条件明明写了但结果不对"的问题,都不是运算符写错,而是踩了下面这几个坑。它们有个共同特征:不报错、不警告,静静地给你一个错误的筛选结果。

2.1 浮点数比较:>= 0 把该留下的记录扔了

先看一段能跑出反直觉结果的代码:

a = 0.1 + 0.2 - 0.3; a >= 0 % 返回 1,看着没问题 a % 5.5511e-17,其实不是 0 b = 0.3 - 0.2 - 0.1; b >= 0 % 返回 0 b % -2.7756e-17

b在数学上是 0,但在双精度下是负的,b >= 0直接判假。放到财务数据上,这个误差会被放大:某个指标理论上正好等于阈值 30%,计算结果却是 29.999999999999996,>= 30判假,一条本该入选的记录就被悄悄淘汰了。这种 bug 最难查,因为它的出现与否取决于具体数值,样本一换就复现不了。

处理办法是引入容差。要么把条件放宽一点,要么用绝对误差判断:

tol = 1e-9; mask = b >= -tol; % 容差版的不小于 mask2 = abs(b) <= tol; % 判断"等于 0"

容差取多大是有讲究的。eps是机器精度,约 2.2e-16,对于经过多步运算的值往往不够用;财务比率一般保留两位小数,取 1e-6 到 1e-9 都合理。我在做比率筛选时统一用1e-8,既不会误纳也不会误杀。另外提醒一句,不要用==比较浮点数,这是同一个问题的另一面。

2.2 if 里的向量化陷阱:条件是真,但代码不走

if语句要求条件表达式能归约成一个标量。如果你在if里写了数组比较,MATLAB 会按"所有元素都为真"来处理,行为看着像对的,其实逻辑已经跑偏了:

x = [1 -2 3]; if x >= 0 disp('进入分支'); % 不会执行,因为有负数 end

更危险的是下面这种:作者本意是"任意一个满足就执行",结果写成了全都要满足,测试用例恰好是全正数,一路绿灯上线,遇到真实数据就静默失效。正确做法是显式归约,让意图写在代码上:

if all(x >= 0) % 全部非负 if any(x >= 0) % 至少一个非负

我的习惯是:只要if或while的条件里出现数组,一律套all或any,哪怕当前确定是标量也套上。这不是洁癖,是给三个月后接手的人(往往就是自己)留个明确提示。

2.3 && 与 & 的分工:短路求值只对标量生效

&&和&的区别不是性能优化,而是语义不同。&&是短路求值,左边为假时右边根本不执行,但它只接受标量;&是逐元素运算,专为数组设计。

v = [1 0 1]; % v && v(1) % 报错:操作数必须能转换为逻辑标量 v & v(1) % 正常:[1 0 1] % 短路的价值:避免无效计算 if ~isempty(data) && mean(data) > 0 % data 为空时不会去算 mean end

上例里如果把&&换成&,isempty(data)为真时mean(data)依然会被求值,返回NaN,虽然结果碰巧还是假,但中间多了一次无意义的计算,遇到更复杂的表达式可能直接抛错。反过来,数组场景硬用&&会报错。判断口诀很简单:标量用&&/||,数组用&/|,混着用迟早出事。

还有一个小细节:~在数组上是逐元素取反,在标量上也是,但~=是比较运算符不是"非等于"。有人习惯写~(x == 0),等价于x ~= 0,但后者可读性更好,也更省一次运算。

3. 把条件变成筛子:白马股这件事本质是一组 >= 和 <=

铺垫到这里,主线可以接上了。所谓"挑白马股",抛开各种玄乎的说法,落地动作就是把一组财务约束翻译成>=、<=的组合,然后对一个全市场的数据表做逻辑索引。它和你写的x(x >= 0)是同一套机制,区别只在于字段更多、阈值需要论证、结果需要人工复核。

3.1 先定义清楚"白马",再谈阈值

"白马股"在中文语境里通常指长期业绩稳定、盈利能力强、现金流健康、分红持续、信息披露规范的一类标的,市场关注度高但不算冷门投机品种。这个描述没法直接写成代码,得拆。我把拆解原则定为三条:一是可量化,每条都能对应到财报里的具体科目;二是可获取,公开定期报告里能算出来,不需要额外调研;三是可复现,换个时间点重跑,规则不变结果可对。

按这三条拆下来,我最终用的是六个维度:盈利能力(净资产收益率)、盈利质量(毛利率及其稳定性)、财务结构(资产负债率)、现金流质量(经营现金流净额与净利润的比值)、股东回报(连续分红年数和股利支付率)、以及若干排除项(连续亏损、风险警示状态、上市年限不足)。每一项都可以映射成一个数值型字段加一个比较运算符,这就是筛选表的雏形。

这里要强调一句:阈值本身没有普适标准。市场上流传的"ROE 大于 15%"这类数字,本质是经验值,在不同行业、不同利率环境下都该调整。我在代码里把阈值全部集中在配置表里,不写死在逻辑中,就是为了随时能换参数重跑。

3.2 六个硬性条件与它们背后的取舍

把上面的维度落成具体规则,我用的版本是这样的:

维度字段含义运算符与阈值设这条的原因
盈利能力近五年平均净资产收益率>= 15长期看,权益回报率是复利速度的上限
产品议价近一年毛利率>= 30毛利是抗成本波动的第一道缓冲
盈利稳定毛利率五年标准差 / 均值<= 0.15波动小说明模式稳,不靠单一时点爆发
财务结构资产负债率<= 60控制杠杆,降低利率环境变化的敏感度
现金流质量经营现金流净额 / 净利润(五年均值)>= 0.8利润要能变成现金,低于这个值要警惕
股东回报连续分红年数 / 股利支付率>= 5且>= 30持续分红是现金真实性的侧面佐证

排除项单独列,因为它们不适合做成连续阈值:是否存在亏损年份、是否处于风险警示状态、上市是否满五年。这三条都是布尔判断,直接用~取反即可。

毛利率稳定性用"标准差除以均值"而不是纯标准差,是为了消除规模差异——一家毛利率 60% 的公司波动 6 个点(10%)和一家 20% 波动 6 个点(30%)不是一个概念。资产负债率的 60% 上限对制造业、消费类大致合适,但银行、券商这类高杠杆行业天然不适用,所以我的代码里留了一个行业白名单字段,把金融类单独拎出去,不参与这条筛选,而不是硬套一个错阈值。

3.3 用逻辑矩阵一次筛完:all、any、sum 的正确组合

单条条件的筛选很简单,多条条件堆一起时,有几种写法,效果差别很大。我见过最多的是嵌套判断,一层套一层,改一条规则要动三层缩进。我的做法是把规则外置成表格,主循环只有三行:

% 规则表:描述 + 匿名函数,一行一条 rules = { "五年平均ROE不低于15%", @(T) T.roe5 >= 15 "毛利率不低于30%", @(T) T.grossMargin >= 30 "毛利率波动率不高于0.15", @(T) T.gmStd ./ T.grossMargin <= 0.15 "资产负债率不高于60%", @(T) T.debtRatio <= 60 "现金利润比不低于0.8", @(T) T.cashToNI >= 0.8 "连续分红不少于5年", @(T) T.divYears >= 5 "股利支付率不低于30%", @(T) T.payout >= 30 "近五年无亏损", @(T) ~T.profitNeg "非风险警示状态", @(T) ~T.isST "上市满五年", @(T) T.listYears >= 5 }; mask = true(height(T), 1); for i = 1:size(rules, 1) mask = mask & rules{i,2}(T); % 注意:用 & 不用 &&,整列逐元素 fprintf('%-24s 通过 %d 条\n', rules{i,1}, sum(mask)); end pool = T(mask, :);

这段代码有几个刻意设计。第一,mask从全true开始,用&逐条收窄,好处是每条规则执行完都能打印一次剩余数量,一眼看出是哪一条把池子砍得太狠。第二,rules用 cell 存匿名函数,加规则只需要多写一行,主循环完全不动。第三,循环里必须用&,因为mask是列向量,用&&会直接报错——这正好呼应前面 2.3 节讲的运算符选择。

诊断输出这块我再多说一句。真实数据跑下来,经常出现某一条规则把池子从 800 砍到 12 的情况,这时候先别急着调阈值,得看是不是字段口径出了问题,比如cashToNI里混进了负的净利润导致比值失真。sum(mask)逐步打印的办法,比对着空结果瞎猜高效得多。

4. 从数据表到候选池:一个能跑通的完整脚本

讲了这么多原则,得给一份能直接复现的代码。下面这套结构我在好几个类似任务里用过,改动点主要是字段名和阈值,骨架不变。为了让代码能独立运行,数据部分用固定随机种子的模拟表代替,实际使用时把数据加载那一段换掉即可。

4.1 字段设计与模拟数据

字段设计的第一原则是命名带单位。财务数据最怕单位混乱,同一个字段一半是百分数一半是小数,筛选结果必然是错的。我的约定是:所有比率类字段统一用百分数(15 表示 15%),比值类字段用无量纲小数(0.8 表示 0.8 倍),年限类用整数。把约定写在表头注释里,比写在文档里管用。

function T = mockFundamentals(n) % 生成演示用的模拟基本面数据,字段含义见右注释 rng(20240101); % 固定种子,结果可复现 name = "M" + string((1:n)'); % 虚构标的编号 T = table(name); T.industry = categorical(randi(8, n, 1)); % 行业分类编号 T.listYears = randi([1 20], n, 1); % 上市年数 T.roe5 = 8 + 10*rand(n,1) + 3*randn(n,1);% 五年平均ROE(%) T.grossMargin = 15 + 35*rand(n,1); % 近一年毛利率(%) T.gmStd = 1 + 8*rand(n,1); % 毛利率五年标准差(%) T.debtRatio = 20 + 55*rand(n,1); % 资产负债率(%) T.cashToNI = 0.3 + 1.1*rand(n,1); % 经营现金流/净利润 T.payout = 10 + 50*rand(n,1); % 股利支付率(%) T.divYears = randi([0 12], n, 1); % 连续分红年数 T.profitNeg = rand(n,1) < 0.10; % 是否存在亏损年份 T.isST = rand(n,1) < 0.08; % 是否风险警示状态 end

模拟数据里我刻意让字段之间没有强相关,这样能看出每条规则的独立效果。真实数据里 ROE 和毛利率通常是正相关的,筛选结果会更集中,这一点在调阈值时要考虑到。

4.2 硬性过滤 + 软性打分两级结构

只用>=筛出来的是"合格",不是"最优"。合格池里可能还剩几十上百个标的,直接看不过来。我的做法是两级:第一级硬性过滤砍掉明显不合格的,第二级对留下的做百分位打分排序,用排名代替绝对阈值,避免"刚好卡线"的标的和"远超阈值"的标的获得相同权重。

% 第二级:百分位打分(0~1,越大越好) pctRank = @(v) (tiedrank(v) - 1) / max(numel(v) - 1, 1); score = 0.30 * pctRank(pool.roe5) ... + 0.20 * pctRank(pool.grossMargin) ... + 0.15 * pctRank(-pool.gmStd ./ pool.grossMargin) ... % 波动越小越好,取负 + 0.15 * pctRank(-pool.debtRatio) ... % 负债越低越好,取负 + 0.10 * pctRank(pool.cashToNI) ... + 0.10 * pctRank(pool.payout); pool.score = score; pool = sortrows(pool, 'score', 'descend'); disp(pool(1:min(10,height(pool)), {'name','roe5','grossMargin','debtRatio','score'}));

几个细节值得说明。用tiedrank而不是normalize,是因为百分位对异常值不敏感——某家公司 ROE 高到离谱,在 z-score 体系里会把其他所有公司压到接近零,而百分位只把它排到第一名,其余的相对位置不受影响。权重加起来等于 1,方便解释;权重本身怎么定是可以调的,我倾向于盈利能力给到 0.3,因为长期回报最终由它决定,其余按重要性递减。

取负号这一步容易被忽略。波动率、负债率都是"越小越好",直接算百分位会把保守的公司排到后面,取负之后方向就对了。这类方向问题一旦搞反,结果会非常离谱,但代码不报错,只能靠人工抽检发现。

4.3 输出与人工复核环节

脚本跑完不是终点。我习惯在输出里带上前三名的关键字段,以及"卡线情况"标记——哪些标的是刚好达到阈值、哪些是明显超出。判断标准是给每条规则算一个余量比例,余量小于 5% 的打标记:

margin.roe = (pool.roe5 - 15) ./ 15; margin.debt = (60 - pool.debtRatio) ./ 60; pool.nearLine = margin.roe < 0.05 | margin.debt < 0.05; fprintf('卡线标的数量:%d\n', sum(pool.nearLine));

这一步的价值在于:阈值附近的标的对参数最敏感。你稍微把 ROE 门槛从 15 调到 14,池子可能多出三成,而这三成里的质量差异是最大的。把它们标出来,人工复核时优先看这批,效率最高。至于复核看什么,那就超出代码范围了——行业景气度、竞争格局、治理结构这些没法从定期报告里直接算出数字的东西,才是决定要不要继续跟下去的关键。代码只负责把范围从几千个缩到几十个,剩下的活还是得人来干。

5. 我在这套流程里踩过的坑与几条经验

代码能跑通和结论能用之间,隔着一段全是坑的路。下面这几条都是实际吃过亏之后总结的,跟语法无关,但踩上去一样难受。

5.1 数据口径不一致,比代码写错更致命

我第一次跑这套筛选的时候,结果里出现了一批明显不该入选的标的。查了半天代码,逻辑没问题,问题出在数据:有几家公司的净资产收益率用的是期末净资产,另几家用的是期初期末平均值,分母差一点,比值能差出几个百分点。更隐蔽的是合并报表和母公司报表混用,营收和利润来自不同口径,算出来的比率毫无意义。

从此我养成了一个习惯:每个字段旁边必须标注来源和计算方式,写在一个单独的元数据表里,字段名、单位、口径、取数期间,一项不落。这件事在字段少的时候显得多余,字段一多就能救命。另外,同一批数据里如果某些字段缺失,务必确认缺失值是NaN还是 0——NaN参与任何比较都返回false,会被静默淘汰;填成 0 则可能让它以极差的值苟活在池子里。两种处理都不理想,正确做法是先统计缺失比例,缺失过多的字段直接不进筛选规则。

5.2 阈值别拍脑袋,用分位数替代绝对值

"ROE 大于 15%"这个数字很流行,但它是在特定市场环境下总结出来的。如果全市场 ROE 的中位数只有 8%,那 15% 就是个很苛刻的门槛,筛出来可能只剩个位数;反过来如果整体盈利能力上行,15% 又太宽松。更稳的做法是先看分布,用分位数定阈值:

q = prctile(T.roe5, [50 75 90]); fprintf('ROE 分位数 50/75/90:%.1f / %.1f / %.1f\n', q(1), q(2), q(3));

看完全市场分布再决定门槛放在 75 分位还是 90 分位,比硬套一个数字靠谱得多。分位数还有个好处:跨年份可比。今年用 80 分位定的阈值,明年再跑一次自动跟着市场整体水平走,不会因为环境变化就筛不出东西。我在脚本里保留了两套模式,绝对阈值用于快速验证,分位数用于定期重跑,后者更耐用。

5.3 筛选结果为空或不合理时的排查顺序

池子突然变空,别急着改阈值。我固定的排查顺序是这样的:

  1. 逐条打印通过数量,看是哪一条把数量砍到 0。这一步在 3.3 节的循环里已经有输出,直接看即可。
  2. 检查该字段的分布,用min、max、prctile看取值范围是否符合预期。曾经遇到过单位错乱,资产负债率存成了 0.45 而不是 45,<= 60全过,但和别的字段一起筛就全军覆没。
  3. 检查缺失与异常值,sum(isnan(T.字段))和sum(T.字段 < 0)。负的股利支付率、负的分红年数这类值通常意味着数据抽取出了问题。
  4. 单独放开一条规则重跑,确认是不是规则之间互斥。比如同时要求"高毛利率"和"低资产负债率",在某些重资产行业里天然冲突,池子空了是规则设计的锅。
  5. 换成绝对阈值做对照,确认不是分位数计算本身出了问题。

这个顺序的逻辑是从规则层往数据层走,因为规则层排查成本最低,一行输出就能定位。反过来一上来就怀疑数据,很容易在无关字段上耗掉半天。

最后再分享一个实操中的体会:筛选脚本写完之后,一定要拿一年前的数据回跑一次,看看当时的结果和现在差多少。如果差异巨大,说明规则对时点太敏感,本质上是在拟合当期市场风格,而不是在找长期稳定的标的。这个自检动作花不了多少时间,但能过滤掉大部分"看着挺好一用就崩"的规则。至于k >= 0那个最初的问题,说到底就是一句话:想清楚你要的是判断、假设还是筛子,选对那个写法,剩下的都是细节。

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

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

立即咨询