跳转至

P3-6.2 当特征本身还不够时,还可以加入什么中间表示

Section ID: P3-6.2 Version: v2026.07.24

平均值、斜率、波动性这样的特征,是很好的出发点。但在某些情况下,仅靠几个数字,仍然很难把区间级结构讲清楚。比如说,假设有一种模式:前段缓慢上升,中段平稳维持,后段快速下落。如果这种结构只留下两三个数字,那么无论是人再去读,还是模型去比较,都可能错过重要的形状差异。所以在 Part 3 里,我们把 中间表示(intermediate representation) 一起看作:它是放在原始日志和汇总特征之间、由人主导的输入重表达,用来把结构保留得更清楚。

这里不会重复讲特征设计本身,而是更集中在:当前一节的数字特征还不足以完整保留结构时,我们还能在什么范围内继续增加区段表示、token 化这样的中间表示。

于是,区段表示和 token 化表示就出现了。核心想法其实很简单。我们不再直接盯着整条很长的原始曲线,而是先把它切成几个区间,再把每个区间的方向和强度,改写成简短符号或简短汇总值。

区间 数值摘要 符号摘要示例
前段 平均上升率为正 UP
中段 平均变化几乎为零 FLAT
后段 下降率较大 DOWN

像下面的图这样把原始曲线分成区段之后,就能看见 token 化并不是单纯给名字,而是在 把曲线的方向和强度改写成更短的读取单位

把原始曲线分成五个区段,并转换成 UP2、UP1、FLAT、DOWN1、DOWN2 token 的图

这样一来,一条长曲线就会缩成像 UP, FLAT, DOWN 这样的短序列。这种表达对人来说容易阅读,对模型来说也更容易把曲线结构接收成长度更规则的输入。也就是说,区段表示是在把复杂时间序列转换成 人和模型都能一起看的中间表示

这种表示也可以按三个层级来读。最简单的层级,只保留方向,比如 UPDOWNFLAT。再细一点的层级,则会同时保留强度,比如 UP1UP2DOWN3。更进一步,还可以再看每个符号重复了几次、在哪些区间里持续更久。这样看,token 化就不是简单替换,而是把同一条原始曲线改写成多种分辨率。

表达层级 保留下来的信息 容易丢失的信息 什么时候有用
只保留方向 上升、下降、平稳 变化幅度差异 需要非常快速比较时
方向 + 强度 上升/下降的强弱 细节波动形状 想增加可解释特征时
连重复长度也保留 同一模式持续了多久 原始时点间隔的细微变化 想看重复性和状态变换时

从这张表可以看出,token 化压得越厉害,就越容易读,但同时也会失去更多信息。因此,应该用哪一层,不是技术偏好,而是问题设定的一部分。是想做一个让运行人员快速扫视的报告,还是想做一个后面还能复用成模型输入的结构,会直接影响应该保留哪种层级的表示。

如果再把 为什么需要这种表示 拆开看一次,区段表示夹在汇总表和原始日志之间所承担的角色就会更清楚。

已经有的东西 还要进一步改写的原因 改写后立刻能看到什么
几个区间平均值 想用更短方式看区间顺序与方向 UP, FLAT, DOWN 这样的模式
一个整体平均值 想看见被平均值掩盖的结构差异 平均值相同,形状不同
整份原始日志 需要一种人能快速比较的中间表示 重复结构的大致轮廓

下面的代码会读取区段斜率 CSV,并用两种设置改变 token 边界,确认同一批原始斜率会怎样变成不同的 token 序列。

问题情境:确认把连续数值斜率转换成短符号列之后,哪些东西会变得更容易看见。

输入(input):按动作整理的区段斜率 CSV p3_6_2_segment_slopes.csv,以及 token 边界候选 token_settings

期望输出(output):每个动作的斜率列表被转换成 UP2UP1FLATDOWN1DOWN2 这样的 token 序列。改变边界值时,保留为 FLAT 的区段数、强上升/强下降 token 数、发生变化的动作列表也会改变。

要确认的概念:token 化不是把原始结构原样保留,而是把顺序和方向改写成更容易读取的中间表示。token 边界不是固定答案,而是要按问题检查的设计值。

# 这个例子在原始日志和最终特征之间加入中间表示,用来追踪计算依据。
import csv
from collections import defaultdict
from pathlib import Path

data_path = Path("docs/assets/part-03/chapter-06/p3_6_2_segment_slopes.csv")
token_settings = {
    "sensitive": {"strong_threshold": 0.80, "weak_threshold": 0.20},
    "conservative": {"strong_threshold": 0.90, "weak_threshold": 0.30},
}


def slope_to_token(slope: float, strong_threshold: float, weak_threshold: float) -> str:
    if slope >= strong_threshold:
        return "UP2"
    if slope >= weak_threshold:
        return "UP1"
    if slope <= -strong_threshold:
        return "DOWN2"
    if slope <= -weak_threshold:
        return "DOWN1"
    return "FLAT"


rows = list(csv.DictReader(data_path.open(encoding="utf-8")))
for row in rows:
    row["segment_order"] = int(row["segment_order"])
    row["slope"] = float(row["slope"])

events = defaultdict(list)
for row in rows:
    events[row["event_id"]].append(row)

reports = {}
for setting_name, thresholds in token_settings.items():
    event_reports = []
    token_counts = defaultdict(int)
    for event_id, event_rows in sorted(events.items()):
        ordered_rows = sorted(event_rows, key=lambda row: row["segment_order"])
        slopes = [row["slope"] for row in ordered_rows]
        tokens = [
            slope_to_token(
                slope,
                thresholds["strong_threshold"],
                thresholds["weak_threshold"],
            )
            for slope in slopes
        ]
        for token in tokens:
            token_counts[token] += 1
        event_reports.append(
            {
                "event_id": event_id,
                "slopes": slopes,
                "tokens": tokens,
            }
        )
    reports[setting_name] = {
        "thresholds": thresholds,
        "events": event_reports,
        "token_counts": dict(sorted(token_counts.items())),
    }

sensitive_tokens = {
    report["event_id"]: report["tokens"]
    for report in reports["sensitive"]["events"]
}
changed_events = []
for report in reports["conservative"]["events"]:
    event_id = report["event_id"]
    if report["tokens"] != sensitive_tokens[event_id]:
        changed_events.append(event_id)

print("1) input rows:", len(rows))
print("2) event count:", len(events))
for setting_name, report in reports.items():
    print(f"[{setting_name}] thresholds =", report["thresholds"])
    print("token_counts =", report["token_counts"])
    for event in report["events"][:3]:
        print(event)
    print()
print("3) events changed when thresholds become conservative:", changed_events)

期望输出:

1) input rows: 40
2) event count: 8
[sensitive] thresholds = {'strong_threshold': 0.8, 'weak_threshold': 0.2}
token_counts = {'DOWN1': 9, 'DOWN2': 3, 'FLAT': 15, 'UP1': 9, 'UP2': 4}
{'event_id': 'A', 'slopes': [0.92, 0.31, 0.05, -0.42, -1.0], 'tokens': ['UP2', 'UP1', 'FLAT', 'DOWN1', 'DOWN2']}
{'event_id': 'B', 'slopes': [0.62, 0.24, 0.01, -0.22, -0.74], 'tokens': ['UP1', 'UP1', 'FLAT', 'DOWN1', 'DOWN1']}
{'event_id': 'C', 'slopes': [0.18, 0.12, 0.04, -0.1, -0.18], 'tokens': ['FLAT', 'FLAT', 'FLAT', 'FLAT', 'FLAT']}

[conservative] thresholds = {'strong_threshold': 0.9, 'weak_threshold': 0.3}
token_counts = {'DOWN1': 7, 'DOWN2': 1, 'FLAT': 23, 'UP1': 7, 'UP2': 2}
{'event_id': 'A', 'slopes': [0.92, 0.31, 0.05, -0.42, -1.0], 'tokens': ['UP2', 'UP1', 'FLAT', 'DOWN1', 'DOWN2']}
{'event_id': 'B', 'slopes': [0.62, 0.24, 0.01, -0.22, -0.74], 'tokens': ['UP1', 'FLAT', 'FLAT', 'FLAT', 'DOWN1']}
{'event_id': 'C', 'slopes': [0.18, 0.12, 0.04, -0.1, -0.18], 'tokens': ['FLAT', 'FLAT', 'FLAT', 'FLAT', 'FLAT']}

3) events changed when thresholds become conservative: ['B', 'D', 'E', 'F', 'H']

在这个输出里,最关键的不只是连续数值被改写成短符号序列的那一刻,还包括边界值改变时哪些动作的解释真的发生变化。这里可以操作的值是 token_settings 里的 strong_thresholdweak_threshold。在保守设置下,更多小变化会保留为 FLAT,像 BDEFH 这样接近边界的动作,token 序列会发生变化。相反,像 A 这样强上升和强下降都很清楚的动作,即使改变设置,主要结构也会保留下来。

把多个动作放在一起看,token 规则不是简单标签,而是设计判断这一点会更清楚。现在,人可以更快地把结构读成 上升、缓慢上升、几乎平、下降、大幅下降,但也能反过来检查:到底是哪一个阈值把哪个区段折成了 FLAT

如果按下面顺序来看这个例子,token 化所承担的作用会更清楚。

  1. 看每个斜率被改写成了哪个 token。
  2. 想一想 token 边界是不是太粗,或者太密。
  3. 试着写一句话:人会怎么读这串 token 序列。

例如,['UP2', 'UP1', 'FLAT', 'DOWN1', 'DOWN2'] 可以总结成 前段上升很强,中间暂时变平,后段下降变得更大

还要注意的一点是:即使平均值相同,token 序列也可能不同。比如,两次动作的平均流量都可能是 2.5,但其中一次是 UP, FLAT, DOWN,另一次却可能是 FLAT, FLAT, FLAT。如果只看平均值,它们会显得相似;但一看 token 序列,就会发现一个发生了结构变化,另一个则维持稳定。正因为这样,token 化不是装饰,而是一种用来补足平均值摘要遗漏结构的表示。

这个差异也可以用一个简单的向量化(vectorization)例子来确认。下面的代码会比较两种排序:一种只按数值平均值排序,另一种先用 TfidfVectorizer 把 token 序列向量化,再按它和查询序列的相似度排序。

import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

patterns = pd.DataFrame(
    [
        {"event_id": "A", "overall_mean": 2.5, "token_sequence": "UP2 UP1 FLAT DOWN1 DOWN2"},
        {"event_id": "B", "overall_mean": 2.5, "token_sequence": "FLAT FLAT FLAT FLAT FLAT"},
        {"event_id": "C", "overall_mean": 2.4, "token_sequence": "UP1 UP1 FLAT DOWN1 DOWN1"},
        {"event_id": "D", "overall_mean": 2.8, "token_sequence": "DOWN2 DOWN1 FLAT UP1 UP2"},
    ]
)

query_mean = 2.5
query_text = "UP2 UP1 FLAT DOWN1 DOWN2"

patterns["mean_distance"] = (patterns["overall_mean"] - query_mean).abs()
mean_rank = patterns.sort_values(["mean_distance", "event_id"])[
    ["event_id", "overall_mean", "mean_distance"]
]

vectorizer = TfidfVectorizer(ngram_range=(1, 2))
matrix = vectorizer.fit_transform(patterns["token_sequence"])
query_vector = vectorizer.transform([query_text])
patterns["token_similarity"] = cosine_similarity(query_vector, matrix)[0]
token_rank = patterns.sort_values(["token_similarity", "event_id"], ascending=[False, True])[
    ["event_id", "token_sequence", "token_similarity"]
]

print("rank by numeric mean")
print(mean_rank.to_string(index=False))
print()
print("rank by token sequence")
print(token_rank.to_string(index=False))

输出如下。

rank by numeric mean
event_id  overall_mean  mean_distance
       A           2.5            0.0
       B           2.5            0.0
       C           2.4            0.1
       D           2.8            0.3

rank by token sequence
event_id           token_sequence  token_similarity
       A UP2 UP1 FLAT DOWN1 DOWN2          1.000000
       C UP1 UP1 FLAT DOWN1 DOWN1          0.511833
       D DOWN2 DOWN1 FLAT UP1 UP2          0.392319
       B FLAT FLAT FLAT FLAT FLAT          0.120765

如果只看数值平均值,AB 是同样接近的候选。但 B 实际上每个区段都是平的,并没有和查询相同的“上升-平坦-下降”结构。把 token 序列向量化之后,A 会变成最接近的候选,而共享部分上升和下降结构的 C 会排到后面。这里的重点并不是说 TfidfVectorizer 就是正确答案。重点是:当人先定义好的区段 token 被转换成真实库可以处理的输入时,我们就能重新比较那些被平均值摘要抹掉的顺序和方向差异。

这一点之所以重要,是因为区段 token 仍然是人自己定规则的表达,但它已经拥有 具有顺序的序列 这一性质。所以,它可以更直接地保留那些数字特征容易漏掉的结构;而到了后面讲顺序数据和表示学习时,也能自然地沿用同一类输入结构继续看下去。

但这里也有明确限制。一旦把曲线变成符号,信息损失就会发生;UPDOWNFLAT 的边界放在哪里,也会带入设计者自己的判断。也就是说,token 化不是万能的,而是一种在获得可解释性的同时,主动丢弃部分细节的压缩。

所以,把这种表示理解成“替代原始日志”,不如理解成放在原始日志和汇总表之间的一种 中间表示 会更安全。原始日志拥有最多信息,汇总表更利于比较,而 token 化表示则在二者之间,让结构更显眼。理解了这种关系,也就更清楚 为什么有些问题只靠平均值还不够,以及 为什么有些问题又不需要每次都回头看整份原始日志。

因此,读 token 化表示时,始终要把两个问题带在一起。因为有了这种表示,什么变得更容易看?改写成这种表示之后,又失去了什么? 只有有了这种平衡感,token 序列才不会像神秘代码,而会被读作一种按目的制造出来的中间表示。

同样的判断,更短地整理一下,就是下面这样。

现在需要的东西 更直接的表达
数值比较和简单模型输入 数值特征
快速读取区间顺序与方向 区段表示
用短符号列比较结构 token 化表示

也就是说,数值特征和中间表示不是竞争关系,而是按“想把什么看得更明显”来分工使用的工具。

这一节与其说是在介绍某一种 token 规则,不如说更接近于:在 原始结构和汇总特征之间,应该放入什么中间表示(intermediate representation between raw structure and summarized features)

用一个小图来看

这一节的核心,是既不把原始曲线立刻丢掉,也不太快把它压成几个数字。把曲线切成区段,再经过数值摘要改写成 token 序列之后,就会多出一层 中间表示

flowchart TD
    R[原始曲线]
    G[切成区段]
    N[数值摘要]
    T[token 序列]
    I[中间表示]

    R --> G --> N --> T --> I

因此,token 化与其被看成独立技巧,不如更准确地读成一种选择:在保留原始日志和过度强压缩之间,究竟要把结构保留到什么分辨率

来源与参考资料

  • TensorFlow, Subword tokenizers. 它把 subword tokenizer 解释成一种位于 word-based tokenization 和 character-based tokenization 之间的表示,因此可以帮助解释一种一般化视角:Part 3 的区段 token 也处在原始日志与强汇总之间的中间表示位置。这里把它直接连到时间序列 token 化的部分,是基于官方说明做出的类比性应用。 https://www.tensorflow.org/text/guide/subwords_tokenizer / 确认日期: 2026-07-20
  • Google for Developers, Machine Learning Glossary 中的 feature engineering。它把 feature engineering 解释为决定哪些变换有助于模型训练的过程,因此支持这样一点:中间表示同样不是原样保留原始值,而是把它们改写成有利于比较和学习的形式。 https://developers.google.com/machine-learning/glossary / 确认日期: 2026-07-20