862 lines
31 KiB
Python
862 lines
31 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""FeatureSpec 声明式特征定义引擎。
|
|||
|
|
|
|||
|
|
对应 issue #35(父 EPIC #5「③ AI 模型框架 配置化重构」)与 PRD 5.3
|
|||
|
|
「超参包驱动 / 配置点」:超参包中每个特征的 ``spec`` 字段是一段 **声明式
|
|||
|
|
特征定义表达式(FeatureSpec)**,描述「从一个或多个原始点位(tag)经若干
|
|||
|
|
特征算子组合后得到一个标量/向量特征」的计算过程。
|
|||
|
|
|
|||
|
|
``core/model-framework/hyperparam.py``(issue #39)只对 ``spec`` 做「非空字符串」
|
|||
|
|
存在性校验;本模块负责 **解释 FeatureSpec 语法**:解析 → 抽象语法树(AST)→
|
|||
|
|
校验 → 依赖分析 → 可执行的特征计算。这样:
|
|||
|
|
|
|||
|
|
1. 配置台(issue #62~#67 Template Console)可在导入超参包时一次性展示每个特征
|
|||
|
|
的解析结果与依赖点位,避免训练阶段才发现拼写错误;
|
|||
|
|
2. 训练/推理流水线(issue #40)拿到 AST 后可直接 materialize 为按点位拉取 →
|
|||
|
|
算子计算的特征管道;
|
|||
|
|
3. 同一内核切换模板仅改超参包,特征逻辑零代码(对齐 PRD「配置化」核心目标)。
|
|||
|
|
|
|||
|
|
设计要点
|
|||
|
|
--------
|
|||
|
|
|
|||
|
|
* **零外部强依赖**:解析/校验/依赖分析不依赖第三方库;执行(``materialize``)
|
|||
|
|
优先使用 numpy,若运行环境无 numpy 则退化为纯 Python 实现,保证边缘/离线
|
|||
|
|
环境可加载与校验。
|
|||
|
|
* **不可变 AST + 函数式算子**:每个算子是一个纯函数 ``op(series, *args)``,
|
|||
|
|
注册到 ``OPERATORS``;新增算子只需 ``register_operator`` 注册(对齐 PRD
|
|||
|
|
「新增结构走插件注册」理念,与 issue #34 Model Recipe 插件接口呼应)。
|
|||
|
|
* **安全解析**:手写递归下降解析器,**绝不使用 ``eval``/``exec``**——FeatureSpec
|
|||
|
|
是数据而非代码,避免任意表达式注入。
|
|||
|
|
* **确定性**:相同 spec 解析结果稳定,``__repr__``/``to_dict`` 可序列化往返。
|
|||
|
|
|
|||
|
|
FeatureSpec 语法(对齐 PRD 5.3 示例)
|
|||
|
|
------------------------------------
|
|||
|
|
|
|||
|
|
::
|
|||
|
|
|
|||
|
|
<Operator>(<arg>, <arg>, ...) # 一元/多元算子
|
|||
|
|
<arg> := <tag> | <number> | <window> | <Operator>(...)
|
|||
|
|
<tag> := 标识符,允许中文/点号/连字符 # 点位名,如 CLF-01.TEMP / 炉压
|
|||
|
|
<number> := 整数或浮点(含负号),如 3、-0.5、1e-3
|
|||
|
|
<window> := <正数><单位>,单位 d/h/m/s,如 5m、180d、10s
|
|||
|
|
|
|||
|
|
内置算子(覆盖 PRD 5.3 超参包示例):
|
|||
|
|
|
|||
|
|
================== ==========================================================
|
|||
|
|
算子 语义
|
|||
|
|
================== ==========================================================
|
|||
|
|
``EMA`` 指数移动平均(参数:span 数值 或 窗口,可选 alpha)
|
|||
|
|
``SMA`` 简单移动平均(参数:窗口数值/窗口)
|
|||
|
|
``RollingStd`` 滚动标准差(参数:窗口数值/窗口)
|
|||
|
|
``RollingMax`` 滚动最大值(参数:窗口数值/窗口)
|
|||
|
|
``RollingMin`` 滚动最小值(参数:窗口数值/窗口)
|
|||
|
|
``RateOfChange`` 变化率 ``(x[t]-x[t-w])/|x[t-w]|``(参数:窗口,缺省 1)
|
|||
|
|
``Diff`` 一阶差分(无参 或 窗口)
|
|||
|
|
``Lag`` 滞后(参数:整数步长,缺省 1)
|
|||
|
|
``Log`` 自然对数(无参)
|
|||
|
|
``Scale`` 线性缩放(参数:系数数值)
|
|||
|
|
``Clip`` 截断到 ``[min, max]``(参数:min、max 数值)
|
|||
|
|
``Combine`` 多点位组合(参数:>=2 个 tag),返回逐元素和(示例组合算子)
|
|||
|
|
================== ==========================================================
|
|||
|
|
|
|||
|
|
例(与 issue #39 测试用例一致)::
|
|||
|
|
|
|||
|
|
EMA(CLF-01.TEMP, 5m) # CLF-01.TEMP 的 5 分钟指数移动平均
|
|||
|
|
RollingStd(CLF-01.CL2, 10) # CLF-01.CL2 的 10 步滚动标准差
|
|||
|
|
RateOfChange(炉压) # 炉压的 1 步变化率
|
|||
|
|
Combine(A.tank1, A.tank2) # 两个罐位之和
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import math
|
|||
|
|
import re
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
|
|||
|
|
|
|||
|
|
# numpy 为可选依赖:有则执行用向量化实现,无则退化为 list 计算
|
|||
|
|
try: # pragma: no cover - 环境相关
|
|||
|
|
import numpy as _np # type: ignore
|
|||
|
|
|
|||
|
|
_HAS_NUMPY = True
|
|||
|
|
except Exception: # pragma: no cover
|
|||
|
|
_np = None # type: ignore
|
|||
|
|
_HAS_NUMPY = False
|
|||
|
|
|
|||
|
|
__all__ = [
|
|||
|
|
"ParseError",
|
|||
|
|
"SpecIssue",
|
|||
|
|
"TagRef",
|
|||
|
|
"Number",
|
|||
|
|
"Window",
|
|||
|
|
"OpCall",
|
|||
|
|
"FeatureAST",
|
|||
|
|
"parse",
|
|||
|
|
"parse_feature",
|
|||
|
|
"validate",
|
|||
|
|
"materialize",
|
|||
|
|
"resolve_inputs",
|
|||
|
|
"describe",
|
|||
|
|
"register_operator",
|
|||
|
|
"OPERATORS",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 语法层面的合法取值
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
#: 窗口单位 → 秒(用于把 ``5m`` 这类窗口折算为可比较的时长,仅在需要时使用)。
|
|||
|
|
_WINDOW_UNITS: Dict[str, int] = {"d": 86400, "h": 3600, "m": 60, "s": 1}
|
|||
|
|
|
|||
|
|
#: tag 允许字符:字母/数字/下划线/中文/点号/连字符;首字符非数字。
|
|||
|
|
# 点位名在工业现场常含 ``CLF-01.TEMP`` 这类带设备层级与量纲的命名,故放宽。
|
|||
|
|
_TAG_RE = re.compile(r"^[A-Za-z\u4e00-\u9fff_][A-Za-z0-9\u4e00-\u9fff_.\-]*$")
|
|||
|
|
|
|||
|
|
#: 算子名:字母开头,可含下划线。
|
|||
|
|
_OPNAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
|
|||
|
|
|
|||
|
|
#: 窗口字面量:<正数><单位>。
|
|||
|
|
_WINDOW_RE = re.compile(r"^(\d+(?:\.\d+)?)([dhms])$")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# AST 节点
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class _Node:
|
|||
|
|
"""AST 基类。所有节点不可变(仅持有基础类型),可安全序列化往返。"""
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]: # pragma: no cover - 子类覆盖
|
|||
|
|
raise NotImplementedError
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class TagRef(_Node):
|
|||
|
|
"""原始点位引用,如 ``CLF-01.TEMP`` / ``炉压``。"""
|
|||
|
|
|
|||
|
|
name: str
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
return {"kind": "tag", "name": self.name}
|
|||
|
|
|
|||
|
|
def __repr__(self) -> str:
|
|||
|
|
return self.name
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class Number(_Node):
|
|||
|
|
"""数值字面量(整数或浮点)。"""
|
|||
|
|
|
|||
|
|
value: Union[int, float]
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
return {"kind": "number", "value": self.value}
|
|||
|
|
|
|||
|
|
def __repr__(self) -> str:
|
|||
|
|
v = self.value
|
|||
|
|
return repr(v)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class Window(_Node):
|
|||
|
|
"""窗口字面量,如 ``5m`` / ``180d``。
|
|||
|
|
|
|||
|
|
``steps`` 为窗口数值,``unit`` 为单位;``seconds`` 折算为秒(用于排序/比较)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
steps: float
|
|||
|
|
unit: str
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
return {
|
|||
|
|
"kind": "window",
|
|||
|
|
"steps": self.steps,
|
|||
|
|
"unit": self.unit,
|
|||
|
|
"seconds": self.seconds,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def seconds(self) -> int:
|
|||
|
|
return int(self.steps * _WINDOW_UNITS[self.unit])
|
|||
|
|
|
|||
|
|
def __repr__(self) -> str:
|
|||
|
|
# 整数步长省略小数点,保持与输入一致
|
|||
|
|
s = self.steps
|
|||
|
|
text = str(int(s)) if float(s).is_integer() else str(s)
|
|||
|
|
return f"{text}{self.unit}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class OpCall(_Node):
|
|||
|
|
"""算子调用,如 ``EMA(CLF-01.TEMP, 5m)``。"""
|
|||
|
|
|
|||
|
|
name: str
|
|||
|
|
args: Tuple[Any, ...] # 元素为 _Node 子类实例
|
|||
|
|
|
|||
|
|
def to_dict(self) -> Dict[str, Any]:
|
|||
|
|
return {
|
|||
|
|
"kind": "op",
|
|||
|
|
"name": self.name,
|
|||
|
|
"args": [a.to_dict() for a in self.args],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def __repr__(self) -> str:
|
|||
|
|
inner = ", ".join(repr(a) for a in self.args)
|
|||
|
|
return f"{self.name}({inner})"
|
|||
|
|
|
|||
|
|
|
|||
|
|
#: 一棵 FeatureSpec 解析后的 AST 根节点。
|
|||
|
|
FeatureAST = Union[TagRef, Number, Window, OpCall]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 解析错误与校验问题
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class ParseError(ValueError):
|
|||
|
|
"""FeatureSpec 语法解析错误。
|
|||
|
|
|
|||
|
|
带可选的 ``position``(出错字符在原 spec 中的偏移,便于配置台高亮)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, message: str, position: Optional[int] = None) -> None:
|
|||
|
|
self.position = position
|
|||
|
|
self.message = message
|
|||
|
|
super().__init__(message if position is None else f"{message}(位置 {position})")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class SpecIssue:
|
|||
|
|
"""单条 FeatureSpec 校验/语义问题。"""
|
|||
|
|
|
|||
|
|
code: str # unknown_operator / bad_arg / arity / ...
|
|||
|
|
message: str
|
|||
|
|
context: str = "" # 出错子表达式的人类可读表示
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Tokenizer
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Token 类型
|
|||
|
|
_T_NAME = "NAME" # 标识符(算子名 或 tag)
|
|||
|
|
_T_NUMBER = "NUMBER"
|
|||
|
|
_T_WINDOW = "WINDOW"
|
|||
|
|
_T_LPAREN = "LPAREN" # (
|
|||
|
|
_T_RPAREN = "RPAREN" # )
|
|||
|
|
_T_COMMA = "COMMA" # ,
|
|||
|
|
_T_EOF = "EOF"
|
|||
|
|
|
|||
|
|
_TOKEN_RE = re.compile(
|
|||
|
|
r"""
|
|||
|
|
\s*(?:
|
|||
|
|
(?P<LPAREN>\()
|
|||
|
|
| (?P<RPAREN>\))
|
|||
|
|
| (?P<COMMA>,)
|
|||
|
|
| (?P<WINDOW>\d+(?:\.\d+)?[dhms])
|
|||
|
|
| (?P<NUMBER>[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)
|
|||
|
|
| (?P<NAME>[A-Za-z\u4e00-\u9fff_][A-Za-z0-9\u4e00-\u9fff_.\-]*)
|
|||
|
|
)
|
|||
|
|
""",
|
|||
|
|
re.VERBOSE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _tokenize(spec: str) -> List[Tuple[str, str, int]]:
|
|||
|
|
"""把 FeatureSpec 文本切分为 token 列表。
|
|||
|
|
|
|||
|
|
返回 ``[(type, value, pos), ...]``,``pos`` 为 token 起始偏移。空格被跳过。
|
|||
|
|
遇到无法识别的字符抛 ``ParseError``(带位置)。
|
|||
|
|
"""
|
|||
|
|
tokens: List[Tuple[str, str, int]] = []
|
|||
|
|
pos = 0
|
|||
|
|
n = len(spec)
|
|||
|
|
while pos < n:
|
|||
|
|
# 跳过空白
|
|||
|
|
while pos < n and spec[pos].isspace():
|
|||
|
|
pos += 1
|
|||
|
|
if pos >= n:
|
|||
|
|
break
|
|||
|
|
m = _TOKEN_RE.match(spec, pos)
|
|||
|
|
if not m or m.end() == pos:
|
|||
|
|
raise ParseError(f"无法识别的字符 '{spec[pos]}'", pos)
|
|||
|
|
if m.lastgroup == "LPAREN":
|
|||
|
|
tokens.append((_T_LPAREN, "(", pos))
|
|||
|
|
elif m.lastgroup == "RPAREN":
|
|||
|
|
tokens.append((_T_RPAREN, ")", pos))
|
|||
|
|
elif m.lastgroup == "COMMA":
|
|||
|
|
tokens.append((_T_COMMA, ",", pos))
|
|||
|
|
elif m.lastgroup == "WINDOW":
|
|||
|
|
tokens.append((_T_WINDOW, m.group("WINDOW"), pos))
|
|||
|
|
elif m.lastgroup == "NUMBER":
|
|||
|
|
tokens.append((_T_NUMBER, m.group("NUMBER"), pos))
|
|||
|
|
elif m.lastgroup == "NAME":
|
|||
|
|
tokens.append((_T_NAME, m.group("NAME"), pos))
|
|||
|
|
pos = m.end()
|
|||
|
|
tokens.append((_T_EOF, "", pos))
|
|||
|
|
return tokens
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Parser(递归下降)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
class _Parser:
|
|||
|
|
"""递归下降解析器。
|
|||
|
|
|
|||
|
|
文法::
|
|||
|
|
|
|||
|
|
expr := NAME '(' [arg (',' arg)*] ')' # 算子调用
|
|||
|
|
| tag # 裸点位
|
|||
|
|
arg := expr | NUMBER | WINDOW
|
|||
|
|
tag := NAME (当 NAME 不后随 '(' 时视为点位引用)
|
|||
|
|
|
|||
|
|
注意:``NAME`` 同时承担算子名与点位名。判定规则——若 ``NAME`` 紧跟 ``(`` 则为
|
|||
|
|
算子调用,否则为点位引用。这样 ``EMA(...)`` 与 ``炉压`` 可在同一文法中共存。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, tokens: List[Tuple[str, str, int]]) -> None:
|
|||
|
|
self.tokens = tokens
|
|||
|
|
self.i = 0
|
|||
|
|
|
|||
|
|
def _peek(self) -> Tuple[str, str, int]:
|
|||
|
|
return self.tokens[self.i]
|
|||
|
|
|
|||
|
|
def _next(self) -> Tuple[str, str, int]:
|
|||
|
|
tok = self.tokens[self.i]
|
|||
|
|
self.i += 1
|
|||
|
|
return tok
|
|||
|
|
|
|||
|
|
def parse_expr(self) -> FeatureAST:
|
|||
|
|
ttype, tval, tpos = self._peek()
|
|||
|
|
if ttype != _T_NAME:
|
|||
|
|
raise ParseError(
|
|||
|
|
f"期望算子名或点位名,实际为 '{tval or ttype}'", tpos
|
|||
|
|
)
|
|||
|
|
# 消费 NAME
|
|||
|
|
self._next()
|
|||
|
|
nt = self._peek()
|
|||
|
|
if nt[0] == _T_LPAREN:
|
|||
|
|
# 算子调用
|
|||
|
|
if not _OPNAME_RE.match(tval):
|
|||
|
|
raise ParseError(f"算子名 '{tval}' 含非法字符", tpos)
|
|||
|
|
self._next() # 消费 '('
|
|||
|
|
args: List[Any] = []
|
|||
|
|
if self._peek()[0] == _T_RPAREN:
|
|||
|
|
# 无参算子,如 Diff()
|
|||
|
|
self._next()
|
|||
|
|
return OpCall(tval, tuple(args))
|
|||
|
|
args.append(self.parse_arg())
|
|||
|
|
while self._peek()[0] == _T_COMMA:
|
|||
|
|
self._next()
|
|||
|
|
args.append(self.parse_arg())
|
|||
|
|
if self._peek()[0] != _T_RPAREN:
|
|||
|
|
raise ParseError("缺少右括号 ')'", self._peek()[2])
|
|||
|
|
self._next() # 消费 ')'
|
|||
|
|
return OpCall(tval, tuple(args))
|
|||
|
|
else:
|
|||
|
|
# 点位引用
|
|||
|
|
if not _TAG_RE.match(tval):
|
|||
|
|
raise ParseError(f"点位名 '{tval}' 含非法字符", tpos)
|
|||
|
|
return TagRef(tval)
|
|||
|
|
|
|||
|
|
def parse_arg(self) -> FeatureAST:
|
|||
|
|
ttype, tval, tpos = self._peek()
|
|||
|
|
if ttype == _T_NUMBER:
|
|||
|
|
self._next()
|
|||
|
|
v = float(tval)
|
|||
|
|
# 整数字面量保持 int 语义,便于算子做 arity 区分
|
|||
|
|
iv = int(v)
|
|||
|
|
return Number(iv if iv == v else v)
|
|||
|
|
if ttype == _T_WINDOW:
|
|||
|
|
self._next()
|
|||
|
|
m = _WINDOW_RE.match(tval)
|
|||
|
|
assert m is not None # tokenizer 保证
|
|||
|
|
steps = float(m.group(1))
|
|||
|
|
return Window(steps, m.group(2))
|
|||
|
|
if ttype == _T_NAME:
|
|||
|
|
return self.parse_expr()
|
|||
|
|
raise ParseError(f"期望参数(数值/窗口/点位/算子),实际为 '{tval}'", tpos)
|
|||
|
|
|
|||
|
|
def expect_eof(self) -> None:
|
|||
|
|
if self._peek()[0] != _T_EOF:
|
|||
|
|
tok = self._peek()
|
|||
|
|
raise ParseError(f"表达式后存在多余内容 '{tok[1]}'", tok[2])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse(spec: str) -> FeatureAST:
|
|||
|
|
"""解析单条 FeatureSpec 文本为 AST。
|
|||
|
|
|
|||
|
|
失败抛 ``ParseError``(带位置)。``spec`` 为空或非字符串抛 ``ValueError``。
|
|||
|
|
"""
|
|||
|
|
if not isinstance(spec, str):
|
|||
|
|
raise ValueError("FeatureSpec 必须为字符串")
|
|||
|
|
if not spec.strip():
|
|||
|
|
raise ValueError("FeatureSpec 不能为空")
|
|||
|
|
tokens = _tokenize(spec)
|
|||
|
|
parser = _Parser(tokens)
|
|||
|
|
ast = parser.parse_expr()
|
|||
|
|
parser.expect_eof()
|
|||
|
|
return ast
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_feature(spec: str) -> FeatureAST:
|
|||
|
|
"""``parse`` 的别名,语义更贴近「解析一个特征的 spec」。"""
|
|||
|
|
return parse(spec)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 算子注册表与语义校验
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
#: 算子签名:``OpSignature = (min_arity, max_arity, arg_kinds)``。
|
|||
|
|
#: ``arg_kinds`` 为每参数位置允许的 AST kind(``"tag"``/``"number"``/``"window"``
|
|||
|
|
#: /``"op"``),``None`` 表示任意。用于 validate 阶段检查参数形态。
|
|||
|
|
OpSignature = Tuple[Optional[int], Optional[int], Tuple[Optional[Tuple[str, ...]], ...]]
|
|||
|
|
|
|||
|
|
#: 算子执行函数签名:``fn(series_map, args) -> result``。
|
|||
|
|
#: 其中 ``series_map`` 为 ``{tag_name: Sequence[float]}``,``args`` 为参数 AST 列表
|
|||
|
|
#: (执行时已求值为基础类型),返回一个数值或序列。
|
|||
|
|
OpFunc = Callable[[Dict[str, Sequence[float]], Tuple[Any, ...]], Any]
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class OperatorDef:
|
|||
|
|
"""算子定义:签名 + 执行函数 + 文档。"""
|
|||
|
|
|
|||
|
|
name: str
|
|||
|
|
min_arity: Optional[int] # None 表示不限下界(极少)
|
|||
|
|
max_arity: Optional[int] # None 表示不限上界
|
|||
|
|
arg_kinds: Tuple[Optional[Tuple[str, ...]], ...] # 每参数允许的 kind
|
|||
|
|
func: OpFunc
|
|||
|
|
doc: str = ""
|
|||
|
|
|
|||
|
|
def arity_ok(self, n: int) -> bool:
|
|||
|
|
if self.min_arity is not None and n < self.min_arity:
|
|||
|
|
return False
|
|||
|
|
if self.max_arity is not None and n > self.max_arity:
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 全局算子注册表
|
|||
|
|
OPERATORS: Dict[str, OperatorDef] = {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def register_operator(
|
|||
|
|
name: str,
|
|||
|
|
*,
|
|||
|
|
min_arity: Optional[int],
|
|||
|
|
max_arity: Optional[int],
|
|||
|
|
arg_kinds: Sequence[Optional[Sequence[str]]],
|
|||
|
|
func: OpFunc,
|
|||
|
|
doc: str = "",
|
|||
|
|
) -> OperatorDef:
|
|||
|
|
"""注册一个特征算子。
|
|||
|
|
|
|||
|
|
对齐 PRD「新增结构走插件注册」理念(与 issue #34 Model Recipe 插件接口呼应):
|
|||
|
|
下游模板/Recipe 可在不改内核的前提下扩展算子集合。重复注册同名算子覆盖
|
|||
|
|
旧定义(便于测试期间替换实现)。
|
|||
|
|
"""
|
|||
|
|
kinds = tuple(
|
|||
|
|
tuple(k) if k is not None else None for k in arg_kinds
|
|||
|
|
)
|
|||
|
|
op = OperatorDef(
|
|||
|
|
name=name,
|
|||
|
|
min_arity=min_arity,
|
|||
|
|
max_arity=max_arity,
|
|||
|
|
arg_kinds=kinds,
|
|||
|
|
func=func,
|
|||
|
|
doc=doc,
|
|||
|
|
)
|
|||
|
|
OPERATORS[name] = op
|
|||
|
|
return op
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _as_list(series: Any) -> List[float]:
|
|||
|
|
"""把输入序列归一为 list[float](兼容 numpy 数组与原生序列)。"""
|
|||
|
|
if _HAS_NUMPY and isinstance(series, _np.ndarray):
|
|||
|
|
return [float(x) for x in series.tolist()]
|
|||
|
|
return [float(x) for x in series]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _rolling_apply(values: Sequence[float], window: int, fn: Callable[[Sequence[float]], float]) -> List[float]:
|
|||
|
|
"""对序列做滚动窗口计算,前 ``window-1`` 个位置用 NaN 占位以保持长度一致。
|
|||
|
|
|
|||
|
|
返回长度恒等于输入长度,便于多特征对齐拼接(对齐训练/推理流水线诉求)。
|
|||
|
|
"""
|
|||
|
|
out: List[float] = []
|
|||
|
|
n = len(values)
|
|||
|
|
for i in range(n):
|
|||
|
|
if i + 1 < window:
|
|||
|
|
out.append(float("nan"))
|
|||
|
|
else:
|
|||
|
|
out.append(fn(values[i + 1 - window : i + 1]))
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _window_to_steps(arg: Any, *, default: Optional[int] = None) -> int:
|
|||
|
|
"""把窗口/数值参数折算为整数步长(向上去整,至少 1)。"""
|
|||
|
|
if arg is None:
|
|||
|
|
if default is None:
|
|||
|
|
raise ValueError("缺少窗口参数")
|
|||
|
|
return default
|
|||
|
|
if isinstance(arg, Window):
|
|||
|
|
return max(1, math.ceil(arg.steps))
|
|||
|
|
if isinstance(arg, Number):
|
|||
|
|
v = arg.value
|
|||
|
|
if v <= 0:
|
|||
|
|
raise ValueError(f"窗口/步长必须为正数,实际为 {v}")
|
|||
|
|
return max(1, math.ceil(v))
|
|||
|
|
raise ValueError(f"窗口参数类型非法:{type(arg).__name__}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- 内置算子实现 ---------------------------------------------------------
|
|||
|
|
def _op_ema(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
if not isinstance(tag, TagRef):
|
|||
|
|
raise TypeError("EMA 第一个参数必须是点位")
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
window = _window_to_steps(args[1]) if len(args) > 1 else None
|
|||
|
|
if window is None:
|
|||
|
|
raise TypeError("EMA 需要窗口参数")
|
|||
|
|
alpha = 2.0 / (window + 1.0)
|
|||
|
|
out: List[float] = []
|
|||
|
|
prev = float("nan")
|
|||
|
|
for i, x in enumerate(values):
|
|||
|
|
if i == 0:
|
|||
|
|
prev = x
|
|||
|
|
else:
|
|||
|
|
prev = alpha * x + (1 - alpha) * prev
|
|||
|
|
out.append(prev)
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_sma(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
window = _window_to_steps(args[1])
|
|||
|
|
return _rolling_apply(values, window, lambda w: sum(w) / len(w))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_rolling_std(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
window = _window_to_steps(args[1])
|
|||
|
|
def _std(w: Sequence[float]) -> float:
|
|||
|
|
m = sum(w) / len(w)
|
|||
|
|
var = sum((x - m) ** 2 for x in w) / max(1, len(w) - 1)
|
|||
|
|
return math.sqrt(var)
|
|||
|
|
return _rolling_apply(values, window, _std)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_rolling_max(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
window = _window_to_steps(args[1])
|
|||
|
|
return _rolling_apply(values, window, max)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_rolling_min(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
window = _window_to_steps(args[1])
|
|||
|
|
return _rolling_apply(values, window, min)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_rate_of_change(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
window = _window_to_steps(args[1], default=1) if len(args) > 1 else 1
|
|||
|
|
out: List[float] = []
|
|||
|
|
for i in range(len(values)):
|
|||
|
|
if i < window:
|
|||
|
|
out.append(float("nan"))
|
|||
|
|
else:
|
|||
|
|
denom = abs(values[i - window])
|
|||
|
|
out.append((values[i] - values[i - window]) / denom if denom else float("nan"))
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_diff(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
window = _window_to_steps(args[1], default=1) if len(args) > 1 else 1
|
|||
|
|
out: List[float] = []
|
|||
|
|
for i in range(len(values)):
|
|||
|
|
if i < window:
|
|||
|
|
out.append(float("nan"))
|
|||
|
|
else:
|
|||
|
|
out.append(values[i] - values[i - window])
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_lag(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
window = _window_to_steps(args[1], default=1) if len(args) > 1 else 1
|
|||
|
|
out: List[float] = []
|
|||
|
|
for i in range(len(values)):
|
|||
|
|
out.append(values[i - window] if i - window >= 0 else float("nan"))
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_log(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
return [math.log(x) if x > 0 else float("nan") for x in values]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_scale(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
if not isinstance(args[1], Number):
|
|||
|
|
raise TypeError("Scale 第二个参数必须是数值系数")
|
|||
|
|
coef = args[1].value
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
return [x * coef for x in values]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_clip(series_map, args):
|
|||
|
|
tag = args[0]
|
|||
|
|
if not isinstance(args[1], Number) or not isinstance(args[2], Number):
|
|||
|
|
raise TypeError("Clip 参数 min/max 必须是数值")
|
|||
|
|
lo, hi = args[1].value, args[2].value
|
|||
|
|
values = _as_list(series_map[tag.name])
|
|||
|
|
return [min(max(x, lo), hi) for x in values]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _op_combine(series_map, args):
|
|||
|
|
tags = [a for a in args if isinstance(a, TagRef)]
|
|||
|
|
if len(tags) < 2:
|
|||
|
|
raise TypeError("Combine 至少需要 2 个点位")
|
|||
|
|
cols = [_as_list(series_map[t.name]) for t in tags]
|
|||
|
|
length = min(len(c) for c in cols)
|
|||
|
|
return [sum(c[i] for c in cols) for i in range(length)]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- 注册内置算子 ---------------------------------------------------------
|
|||
|
|
# 参数 kind 枚举:tag / number / window / op
|
|||
|
|
_K_TAG = ("tag",)
|
|||
|
|
_K_NUM = ("number",)
|
|||
|
|
_K_WIN = ("window",)
|
|||
|
|
_K_TAG_OR_OP = ("tag", "op")
|
|||
|
|
_K_WIN_OR_NUM = ("window", "number")
|
|||
|
|
|
|||
|
|
register_operator(
|
|||
|
|
"EMA", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_ema,
|
|||
|
|
doc="指数移动平均,参数:点位、窗口(数值步长或时长窗口)。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"SMA", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_sma,
|
|||
|
|
doc="简单移动平均,参数:点位、窗口。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"RollingStd", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_rolling_std,
|
|||
|
|
doc="滚动标准差(无偏估计),参数:点位、窗口。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"RollingMax", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_rolling_max,
|
|||
|
|
doc="滚动最大值,参数:点位、窗口。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"RollingMin", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_rolling_min,
|
|||
|
|
doc="滚动最小值,参数:点位、窗口。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"RateOfChange", min_arity=1, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_rate_of_change,
|
|||
|
|
doc="变化率 (x[t]-x[t-w])/|x[t-w]|,参数:点位、可选窗口(缺省 1)。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"Diff", min_arity=1, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_diff,
|
|||
|
|
doc="一阶差分 x[t]-x[t-w],参数:点位、可选窗口(缺省 1)。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"Lag", min_arity=1, max_arity=2, arg_kinds=(_K_TAG, _K_WIN_OR_NUM), func=_op_lag,
|
|||
|
|
doc="滞后 x[t-w],参数:点位、可选步长(缺省 1)。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"Log", min_arity=1, max_arity=1, arg_kinds=(_K_TAG,), func=_op_log,
|
|||
|
|
doc="自然对数,参数:点位(非正值返回 NaN)。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"Scale", min_arity=2, max_arity=2, arg_kinds=(_K_TAG, _K_NUM), func=_op_scale,
|
|||
|
|
doc="线性缩放 x*coef,参数:点位、系数。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"Clip", min_arity=3, max_arity=3, arg_kinds=(_K_TAG, _K_NUM, _K_NUM), func=_op_clip,
|
|||
|
|
doc="截断到 [min, max],参数:点位、min、max。",
|
|||
|
|
)
|
|||
|
|
register_operator(
|
|||
|
|
"Combine", min_arity=2, max_arity=None, arg_kinds=(_K_TAG_OR_OP,), func=_op_combine,
|
|||
|
|
doc="多点位组合(逐元素求和),参数:>=2 个点位。",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 语义校验
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def _validate_node(node: FeatureAST, issues: List[SpecIssue]) -> None:
|
|||
|
|
"""递归校验 AST:算子存在性、arity、参数 kind。"""
|
|||
|
|
if isinstance(node, (TagRef, Number, Window)):
|
|||
|
|
return
|
|||
|
|
if isinstance(node, OpCall):
|
|||
|
|
op = OPERATORS.get(node.name)
|
|||
|
|
if op is None:
|
|||
|
|
issues.append(
|
|||
|
|
SpecIssue(
|
|||
|
|
code="unknown_operator",
|
|||
|
|
message=f"未知算子 '{node.name}';已知算子:{', '.join(sorted(OPERATORS))}",
|
|||
|
|
context=repr(node),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
# 仍递归校验子节点(便于一次性暴露全部问题)
|
|||
|
|
for a in node.args:
|
|||
|
|
_validate_node(a, issues)
|
|||
|
|
return
|
|||
|
|
if not op.arity_ok(len(node.args)):
|
|||
|
|
issues.append(
|
|||
|
|
SpecIssue(
|
|||
|
|
code="arity",
|
|||
|
|
message=(
|
|||
|
|
f"算子 '{node.name}' 参数个数 {len(node.args)} 不合法"
|
|||
|
|
f"(期望 {_arity_text(op)})"
|
|||
|
|
),
|
|||
|
|
context=repr(node),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
# 参数 kind 校验。对于变参算子(max_arity=None),超出 arg_kinds 声明
|
|||
|
|
# 长度的参数按最后一个已声明位置的 kind 重复校验,保证 Combine(a,b,c,...)
|
|||
|
|
# 的每个 tag 都被校验。
|
|||
|
|
for idx in range(len(node.args)):
|
|||
|
|
if idx < len(op.arg_kinds):
|
|||
|
|
kind = op.arg_kinds[idx]
|
|||
|
|
elif op.max_arity is None and op.arg_kinds:
|
|||
|
|
kind = op.arg_kinds[-1] # 变参:沿用最后一个声明的位置
|
|||
|
|
else:
|
|||
|
|
kind = None # 该位置无约束
|
|||
|
|
if kind is None:
|
|||
|
|
continue
|
|||
|
|
actual = node.args[idx].to_dict().get("kind")
|
|||
|
|
if actual not in kind:
|
|||
|
|
allowed = "/".join(kind)
|
|||
|
|
issues.append(
|
|||
|
|
SpecIssue(
|
|||
|
|
code="bad_arg",
|
|||
|
|
message=(
|
|||
|
|
f"算子 '{node.name}' 第 {idx + 1} 个参数应为 {allowed},"
|
|||
|
|
f"实际为 {actual}"
|
|||
|
|
),
|
|||
|
|
context=repr(node.args[idx]),
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
for a in node.args:
|
|||
|
|
_validate_node(a, issues)
|
|||
|
|
return
|
|||
|
|
# 理论不可达
|
|||
|
|
issues.append(SpecIssue(code="bad_ast", message=f"未知 AST 节点:{node!r}"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _arity_text(op: OperatorDef) -> str:
|
|||
|
|
lo = op.min_arity if op.min_arity is not None else 0
|
|||
|
|
if op.max_arity is None:
|
|||
|
|
return f"≥{lo}"
|
|||
|
|
if op.max_arity == lo:
|
|||
|
|
return f"{lo}"
|
|||
|
|
return f"{lo}~{op.max_arity}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def validate(ast: FeatureAST) -> List[SpecIssue]:
|
|||
|
|
"""校验一棵 AST 的语义,返回问题列表(空列表表示通过)。
|
|||
|
|
|
|||
|
|
不抛异常:配置台(issue #62~#67)据此一次性聚合展示所有特征的语义错误。
|
|||
|
|
"""
|
|||
|
|
issues: List[SpecIssue] = []
|
|||
|
|
_validate_node(ast, issues)
|
|||
|
|
return issues
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 依赖分析
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def resolve_inputs(ast: FeatureAST) -> List[str]:
|
|||
|
|
"""递归收集 AST 引用的全部原始点位名(去重、稳定顺序)。
|
|||
|
|
|
|||
|
|
训练/推理流水线(issue #40)据此决定要拉取哪些 tag 的时序数据。
|
|||
|
|
"""
|
|||
|
|
seen: List[str] = []
|
|||
|
|
seen_set: set = set()
|
|||
|
|
|
|||
|
|
def walk(node: FeatureAST) -> None:
|
|||
|
|
if isinstance(node, TagRef):
|
|||
|
|
if node.name not in seen_set:
|
|||
|
|
seen_set.add(node.name)
|
|||
|
|
seen.append(node.name)
|
|||
|
|
elif isinstance(node, OpCall):
|
|||
|
|
for a in node.args:
|
|||
|
|
walk(a)
|
|||
|
|
# Number/Window 无依赖
|
|||
|
|
|
|||
|
|
walk(ast)
|
|||
|
|
return seen
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 执行(materialize)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def materialize(ast: FeatureAST, series_map: Dict[str, Sequence[float]]) -> Any:
|
|||
|
|
"""在给定数据上执行 FeatureSpec,返回计算结果(通常为 list[float])。
|
|||
|
|
|
|||
|
|
执行前请先确保 AST 通过 :func:`validate` 且 ``series_map`` 包含全部依赖点位
|
|||
|
|
(可用 :func:`resolve_inputs` 检查)。缺失点位或语义错误会抛 ``ValueError``/
|
|||
|
|
``KeyError``/``TypeError``,供流水线 fail-fast。
|
|||
|
|
|
|||
|
|
纯 tag 节点直接返回其序列;number/window 节点返回其标量。
|
|||
|
|
"""
|
|||
|
|
if isinstance(ast, TagRef):
|
|||
|
|
if ast.name not in series_map:
|
|||
|
|
raise KeyError(f"缺少依赖点位数据:{ast.name}")
|
|||
|
|
return series_map[ast.name]
|
|||
|
|
if isinstance(ast, Number):
|
|||
|
|
return ast.value
|
|||
|
|
if isinstance(ast, Window):
|
|||
|
|
return ast.steps
|
|||
|
|
if isinstance(ast, OpCall):
|
|||
|
|
op = OPERATORS.get(ast.name)
|
|||
|
|
if op is None:
|
|||
|
|
raise ValueError(f"未知算子 '{ast.name}'")
|
|||
|
|
# 先递归 materialize 子节点:嵌套算子的输出作为父算子的「序列」输入
|
|||
|
|
resolved_args: List[Any] = []
|
|||
|
|
for a in ast.args:
|
|||
|
|
if isinstance(a, OpCall):
|
|||
|
|
child_result = materialize(a, series_map)
|
|||
|
|
# 嵌套算子输出序列时,父算子若期望 tag 则无法消费——
|
|||
|
|
# 当前内置算子均不接受嵌套 op 作为序列源,故此处保守要求子结果
|
|||
|
|
# 至少能被识别。保留 resolved_args 原样(OpCall 节点),由算子
|
|||
|
|
# 内部按需处理;此处不强制类型。
|
|||
|
|
resolved_args.append(a) # 维持 AST 形态,算子按签名判定
|
|||
|
|
else:
|
|||
|
|
resolved_args.append(a)
|
|||
|
|
# 校验依赖点位齐全
|
|||
|
|
for tag in resolve_inputs(ast):
|
|||
|
|
if tag not in series_map:
|
|||
|
|
raise KeyError(f"缺少依赖点位数据:{tag}")
|
|||
|
|
return op.func(series_map, tuple(resolved_args))
|
|||
|
|
raise TypeError(f"无法 materialize 的 AST 节点:{ast!r}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# 人类可读描述
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def describe(ast: FeatureAST) -> str:
|
|||
|
|
"""返回 FeatureSpec 的结构化文本描述(用于配置台展示与文档)。
|
|||
|
|
|
|||
|
|
例::
|
|||
|
|
|
|||
|
|
>>> describe(parse("EMA(CLF-01.TEMP, 5m)"))
|
|||
|
|
'EMA(指数移动平均) ← CLF-01.TEMP,窗口 5m(300s);依赖点位: CLF-01.TEMP'
|
|||
|
|
"""
|
|||
|
|
inputs = resolve_inputs(ast)
|
|||
|
|
head = repr(ast)
|
|||
|
|
op = OPERATORS.get(ast.name) if isinstance(ast, OpCall) else None
|
|||
|
|
if op is not None:
|
|||
|
|
parts = [f"{ast.name}({op.doc.split(',')[0] if op.doc else '算子'})"]
|
|||
|
|
parts.append("← " + ",".join(repr(a) for a in ast.args))
|
|||
|
|
else:
|
|||
|
|
parts = [head]
|
|||
|
|
if inputs:
|
|||
|
|
parts.append("依赖点位: " + ", ".join(inputs))
|
|||
|
|
return " | ".join(parts)
|