feat(#35): FeatureSpec 声明式特征定义引擎(PRD 5.3 特征 spec 语义解释)
新增 core/model-framework/feature_spec.py:把超参包中特征 spec 字段从 「非空字符串存在性校验」(issue #39)升级为可解释的声明式特征定义引擎。 - 手写递归下降解析器(tokenizer+parser),安全无 eval/exec;支持算子调用、 裸点位、数值/窗口字面量、嵌套、中文点位、带符号/科学计数数值。 - 不可变 AST(TagRef/Number/Window/OpCall),to_dict/repr 可序列化往返。 - 语义校验:未知算子、arity、参数 kind;返回 SpecIssue 列表供配置台聚合展示。 - 依赖分析 resolve_inputs:去重、稳定顺序,供训练/推理流水线按 tag 拉取数据。 - materialize 执行:EMA/SMA/RollingStd/Max/Min/RateOfChange/Diff/Lag/Log/ Scale/Clip/Combine 共 12 个内置算子,缺数据 fail-fast;numpy 可选依赖。 - 插件注册 register_operator:对齐 PRD「新增结构走插件注册」(呼应 #34 Recipe)。 - 49 个单元测试全通过;零内核改动,纯新增。
This commit is contained in:
@@ -0,0 +1,861 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user