极具特色的位置运算

集合在计算机中一般都存储为数组形式,其成员天然会有个位置。数据表本质上是记录的集合,也会被存储成数组,作为成员的记录也有位置的概念。而实际应用中确实有很多分析计算都是位置相关的,但 SQL 把数据表处理成无序集合,就会很不方便。

esProc SPL 保留了数组的有序性,并提供了丰富的位置运算方法,可以用简洁易懂的代码轻松实现这类计算需求。

1. 位置序号

在有序数据集中,记录的位置本身就具有业务意义。比如我们要取某股票 2025 年第 5、10、15、20…交易日的数据。简单的思路是在交易日有序的股票数据中,过滤出 2025 年的记录集合,从中取第 5、10、15、20…条记录就可以了。

无序的 SQL 要用窗口函数临时造出个序号才能完成这样的任务,思路有点“绕”:

SELECT *
FROM (
    SELECT *,
        ROW_NUMBER() OVER (ORDER BY date) AS rn
    FROM 
        stock
    WHERE 
        YEAR(date) = 2025
)
WHERE 
   rn % 5 = 0;

SPL 支持对记录的位置序号进行计算,可以按照上述简单思路轻松解题:

stock.select(year(date)==2025).select(# % 5==0)

在对数据集循环计算时,# 是当前记录的位置序号。第二个 select 的条件是记录序号 #能被 5 整除,过滤结果自然就是第 5、10、15、20…条记录了。

Python 的源头是常规程序语言,也继承了数组成员位置的概念,相应的代码也较为简洁:

stock_2025 = stock[stock['date'].dt.year == 2025].reset_index(drop=True)
selected_stock = stock_2025[stock_2025.index % 5 == 4]

index 是 Dataframe 对象的默认索引,和 SPL 的 #不同,它是从 0 开始的整数。这个索引是个对象,会占用内存空间。

index 是生成 stock 时自动计算产生的,stock 按年份过滤后必须重置 index,对新的集合重新生成索引,否则 stock_2025.index 得到的还是年份过滤前的位置序号。

而 SPL 中的 #是天然序号,不需要额外的存储空间,也没有重置动作,更加简洁轻便。

Python 还提供了 iloc 函数,能直接用位置取数据:

stock_2025 = stock[stock['date'].dt.year == 2025].reset_index(drop=True)
selected_stock = stock_2025.iloc[4::5]

iloc[4::5] 从整数位置 4(也就是第 5 行)开始,每隔 5 行选取一条数据。

SPL 也可以用类似的方式计算:

stock.select(year(date)==2025).step(5,5)

2. 按条件取位置

条件过滤是常见运算,也就是取得满足一定条件的成员。集合成员有序时,我们有时候还会关心这些满足条件的成员的位置。

比如求某股票上市后,经过多少个交易日涨到了 100 元以上。简单思路是在交易日有序的股票数据中,找出第一个超过 100 元记录的位置序号,就是想要的结果。

SQL 要造个序号,再计算收盘价在 100 元以上记录对应的序号的最小值,思路还是“绕”:

SELECT MIN(rn) 
FROM (
    SELECT price,
        ROW_NUMBER() OVER ( ORDER BY date ) rn 
    FROM stock)
WHERE price>100

SPL 则轻松完成任务:

stock.pselect(price > 100)

pselect 是 SPL 基于天然位置序号提供的定位函数,用于获得满足条件成员的位置,这里就是第一个大于 100 元记录的位置序号。

Python 使用 Dataframe 索引写出的代码:

stock [stock['price'] > 100].index[0] + 1

相当于常规过滤后取出索引,略有些繁琐。

单纯取得位置经常并不是目标,我们还要用这个位置做进一步的计算。比如:求股价第一次涨到 100 元以上时的涨幅。自然的思路是:找到第一条收盘价大于 100 元的记录位置,取出这个位置和前一个位置上记录的收盘价,两者相减就可以了。

但是,用 SQL 实现就会“绕”得多了:

WITH T AS (
    SELECT price,ROW_NUMBER() OVER ( ORDER BY date ) rn,
           price - LAG(price) OVER ( ORDER BY date) rising
    FROM Stock)
SELECT Rising FROM T WHERE NO = ( SELECT MIN(rn) FROM T WHERE price>100 )

这要把每天的涨幅都计算出来,CTE 语法生成的中间表也要被遍历两次。

SPL 可以按照自然思路实现,代码非常简捷:

p=stock.pselect(price > 4.5)
stock(p).price-stock(p-1).price

SPL 提供了定位计算函数 calc,可以进一步简化这个代码:

stock.calc(stock.pselect(price>100),price-price[-1])

calc 函数针对 stock.pselect(price>100) 得到的位置,计算表达式 price-price[-1]。SPL 支持跨行计算,price[-1] 表示上一行的收盘价。

Python 有位置概念,也能写出类似的代码:

first_index = stock[stock['price'] > 100].index[0]
result =stock.loc[first_index,'price']- stock.loc[first_index - 1,'price']

不过 Python 没有提供类似 calc 的函数,就只能将 loc 写两次。

3. 取最值位置

有序数据集中,最大、最小值记录所在位置也很有业务意义。

比如求股价第一次达到最高价那天的涨幅。简单思路是在有序的股票数据中,找到第一个达到最高价的记录位置,就很容易解题了。

SQL 写起来还是很麻烦:

WITH T AS (
    SELECT 
        price,
        ROW_NUMBER() OVER (ORDER BY date) rn,
        price - LAG(price) OVER (ORDER BY date) AS rising
    FROM 
        Stock
),
SELECT rising
FROM T
WHERE price = (SELECT MAX(price) FROM T)
ORDER BY rn
LIMIT 1

窗口函数多,遍历次数多,最后还要先排序再取第一条记录。

SPL 提供了返回最大、最小值出现位置的定位函数 pmax 和 pmin,可以写出非常简捷的代码:

stock.calc(stock.pmax(price ), price - price[-1])

pmax 默认是从前向后找第一个最大值位置序号。

最大值成员可能有多个,假设要找所有最大值对应的涨幅,SPL 这样写:

stock.calc(stock.pmax@a(price ), price - price[-1])

@a 选项可以返回所有这些成员的位置。

Python 的函数 idxmax 也可以返回最大值对应的第一条记录位置:

max_idx = stock['price'].idxmax()
increase = stock.loc[max_idx,'price']- stock.loc[max_idx - 1,'price']

像前面代码一样,需要写两次 loc。

如果取所有最大值对应涨幅,Python 的写法就比较啰嗦了:

max_price = stock['price'].max()
max_idxs = stock[stock['price'] == max_price].index
increase=max_price - stock.loc[max_idxs-1,'price'].values

Python 没有提供返回所有最大值位置的函数,只能先求出最大值,过滤出最大值所在记录,最后用 index 求这些记录的位置。

小结一下:SQL 对付有序集合的位置计算非常麻烦,要临时造序号,思路很绕、代码繁琐。Python 有整数索引,也可以按位置取成员,比 SQL 要方便很多,但索引不够自动化,常常要重置,位置运算也不够丰富,类似任务的实现方法也不一致,较复杂些的任务仍显啰嗦,理解难度也大。SPL 位置运算很独特,有简洁的天然序号,和丰富的定位函数,可以轻松实现此类任务。