时序数据的表征学习方法(二)——time2vec
- 2020 年 12 月 15 日
- AI
时间序列数据的embedding是一个很有意思,略小众的领域,这里介绍三种:
1、time2vec

这篇论文很有意思,因为它针对的场景是比较广泛的,比如说在风控的好坏用户的二分类问题中,我们的原始数据包括了静态数据,例如用户的性别,身高,籍贯等,也包括了动态数据,例如用户的历史消费金额,风控里常见的做法就是时间窗,滞后算子之类的,time2vec针对于这种场景诞生,作者认为人工去设计不同的时间窗、滞后算子之类的手工特征衍生太麻烦而且需要较多的领域知识,所以想做出一种timeseries的embedding方法将动态的数据转化为静态的embedding向量。
在设计time2vec时,作者确定了三个重要的特性:
1- embedding的结果应该能够捕获周期模式和非周期模式,
2-对时间的变动保持稳定不变(being invariant to time rescaling),
3-足够简单,可以与许多模型相结合。
周期性:在许多场景中,一些事件周期性地发生。 例如,商店的销售额可能在周末或假日更高。 天气状况通常遵循不同季节[16]的周期性模式。 钢琴曲中的一些音符通常周期性地[24]重复。 其他一些事件可能是非周期性的,但只有在一个时间点之后和/或随着时间的推移而变得更有可能发生。 例如,一些疾病更有可能发生在老年。 这种周期性和非周期性模式将时间与其他需要更好的处理和更好地表示时间的特征区分开来。 特别是,使用能够捕获周期模式和非周期模式的表示非常重要。
对时间的变动保持稳定不变:Invariance to Time Rescaling: Since time can be measured in different scales (e.g., days, hours,seconds, etc.), another important property of a representation for time is invariance to time rescaling (see, e.g., [54]). A class C of models is invariant to time rescaling if for any model M1 ∈ C and any scalar α > 0, there exists a model M2 ∈ C that behaves on ατ (τ scaled by α) in the sameway M1 behaves on original τ s.
这里直译感觉怪怪的,举个例子说明这个稳定不变的特性,例如我们有一个序列数据一共100个样本,代表了100秒的样本,此时我们得到了一个time的embedding结果,那么,如果序列此时的时间刻度变成了小时或者天,比如100天,则我们的embedding只要经过某个周期参数的调整就可以和原始的embedding产生相似的结果。
简单性:容易应用到我们的下游任务中去。
time2vec论文中的公式如下

其中ķ是time2vec维度,τ是原始时间序列特征,F是周期性激活函数,ω和φ是一组可学习的参数,也就是time2vec的embedding层中的权重系数的概念。在实验中,为了使得算法能够捕获数据中的周期性行为,F选定为一个正弦函数。与此同时线性项(对应i=0对应的公式)表示时间的非周期进程,用于捕获时间输入中的非周期性模式。
下面是keras的实现,来自于:
//towardsdatascience.com/time2vec-for-time-series-features-encoding-a03a4f3f937e
class T2V(Layer):
def __init__(self, output_dim=None, **kwargs):
self.output_dim = output_dim
super(T2V, self).__init__(**kwargs)
def build(self, input_shape):
self.W = self.add_weight(name='W',
shape=(1, self.output_dim),
initializer='uniform',
trainable=True)
self.P = self.add_weight(name='P',
shape=(1, self.output_dim),
initializer='uniform',
trainable=True)
self.w = self.add_weight(name='w',
shape=(1, 1),
initializer='uniform',
trainable=True)
self.p = self.add_weight(name='p',
shape=(1, 1),
initializer='uniform',
trainable=True)
super(T2V, self).build(input_shape)
def call(self, x):
original = self.w * x + self.p
sin_trans = K.sin(K.dot(x, self.W) + self.P)
return K.concatenate([sin_trans, original], -1)
可以将这里的T2V的layer当作一个基于时间序列的embedding层,对照公式可以看到,实现确实相当简单了。。。其中 original = self.w * x + self.p 用于拟合序列数据的非周期模式,而 sin_trans = K.sin(K.dot(x, self.W) + self.P) 用于拟合序列数据的非周期模式,这里的K.sin使用K.cos来代替也是可以的。最终的输出是将非周期部分和周期部分直接进行concat得到的。
这个用户自定义层的输出维度是用户指定的维度(1≤i≤k),即从网络学到的正弦波(周期特性),加上输入的线性表示(i = 0)。有了这个组件,我们只需要将它与其他层堆叠在一起,然后在案例研究中尝试使用它的强大功能。
可以看到,这里的周期部分设计的比较巧妙,我们常见的通过正弦或者余弦来拟合周期,需要我们自己去指定周期的长度,例如:
我在这篇文章提到的,实际上周期编码等价于一种人工设计的embedding,因为周期是完全根据我们自己来定义的,例如对second进行天级别的周期编码,实际上我们实现知道一天有24*3600秒,即我们实现知道周期的长度。而这里通过nn强大的能力——我们事先不知道周期的长度具体是多少,既然如此,就设计为可训练的参数,让nn来自己通过训练获取岂不美哉?
到这里问题已经解决了一半了,还有一半就是,这个layer怎么用?样本和标签是啥?损失函数有啥特殊的吗?
def T2V_NN(param, dim):
inp = Input(shape=(dim,1))
x = T2V(param['t2v_dim'], dim)(inp)
x = LSTM(param['unit'], activation=param['act'])(x)
x = Dense(1)(x)
m = Model(inp, x)
m.compile(loss='mse', optimizer=Adam(lr=param['lr']))
return m
def NN(param, dim):
inp = Input(shape=(dim,1))
x = LSTM(param['unit'], activation=param['act'])(inp)
x = Dense(1)(x)
m = Model(inp, x)
m.compile(loss='mse', optimizer=Adam(lr=param['lr']))
return m
这里上文的原作者做了一个测试,可以看到,这里T2V embeding layer直接作为embedding层放置在LSTM前面,可以看到。。这个形式和文本分类是非常相似的,文本分类中对词进行embedding然后依次输入LSTM中,而这里通过T2V embedding对连续性的序列数据进行embedding然后送入LSTM中。
这就很有意思了,文本分类里是一个词一个词embedding,而time2vec则是一个连续数据一个连续数据进行embedding,看到这里,可以发现,整体的思路和我之前写过的周期编码的形式是非常类似的。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import random
import os
from sklearn.metrics import mean_absolute_error
import tensorflow as tf
from tensorflow.keras.layers import *
from tensorflow.keras.models import *
from tensorflow.keras.callbacks import *
from tensorflow.keras.optimizers import *
from tensorflow.keras import backend as K
from kerashypetune import KerasGridSearch
### READ DATA ###
df = pd.read_csv('Punta_Salute_2009.csv', sep=';')
df = df.dropna()
print(df.shape)
df.head()
df['Livello P.Salute Canal Grande (cm)'].plot(y='Livello P.Salute Canal Grande (cm)', x='Ora solare', figsize=(8,6))df[:7*24]['Livello P.Salute Canal Grande (cm)'].plot(y='Livello P.Salute Canal Grande (cm)', x='Ora solare', figsize=(8,6))


可以看出,这个序列存在比较明显的周期性,不过具体周期多少不太好看出来,现在我们要对上述序列进行分解,分解为 非周期部分+周期部分
### DEFINE T2V LAYER ###
class T2V(Layer):
def __init__(self, output_dim=None, **kwargs):
self.output_dim = output_dim
super(T2V, self).__init__(**kwargs)
def build(self, input_shape):
self.W = self.add_weight(name='W',
shape=(1, self.output_dim),
initializer='uniform',
trainable=True)
self.P = self.add_weight(name='P',
shape=(1, self.output_dim),
initializer='uniform',
trainable=True)
self.w = self.add_weight(name='w',
shape=(1, 1),
initializer='uniform',
trainable=True)
self.p = self.add_weight(name='p',
shape=(1, 1),
initializer='uniform',
trainable=True)
super(T2V, self).build(input_shape)
def call(self, x):
original = self.w * x + self.p
sin_trans = K.sin(K.dot(x, self.W) + self.P)
return K.concatenate([sin_trans, original], -1)
### CREATE GENERATOR FOR LSTM AND T2V ###
sequence_length = 24
def gen_sequence(id_df, seq_length, seq_cols):
data_matrix = id_df[seq_cols].values
num_elements = data_matrix.shape[0]
for start, stop in zip(range(0, num_elements-seq_length), range(seq_length, num_elements)):
yield data_matrix[start:stop, :]
def gen_labels(id_df, seq_length, label):
data_matrix = id_df[label].values
num_elements = data_matrix.shape[0]
return data_matrix[seq_length:num_elements, :]
随机性的固定和两个模型的构建,一个是普通的,一个是基于time2vec的,进行比较
### DEFINE MODEL STRUCTURES ###
def set_seed_TF2(seed):
tf.random.set_seed(seed)
os.environ['PYTHONHASHSEED'] = str(seed)
np.random.seed(seed)
random.seed(seed)
def T2V_NN(param, dim):
inp = Input(shape=(dim,1))
x = T2V(param['t2v_dim'])(inp)
x = LSTM(param['unit'], activation=param['act'])(x)
x = Dense(1)(x)
m = Model(inp, x)
m.compile(loss='mse', optimizer=Adam(lr=param['lr']))
return m
def NN(param, dim):
inp = Input(shape=(dim,1))
x = LSTM(param['unit'], activation=param['act'])(inp)
x = Dense(1)(x)
m = Model(inp, x)
m.compile(loss='mse', optimizer=Adam(lr=param['lr']))
return m
然后是时间序列数据的处理:
### PREPARE DATA TO FEED MODELS ###
X, Y = [], []
for sequence in gen_sequence(df, sequence_length, ['Livello P.Salute Canal Grande (cm)']):
X.append(sequence)
for sequence in gen_labels(df, sequence_length, ['Livello P.Salute Canal Grande (cm)']):
Y.append(sequence)
X = np.asarray(X)
Y = np.asarray(Y)

这种序列形式的处理之前分享过一个封装好的函数,
这里是过去24个时间步预测未来的1个时间步的简单的单变量时间序列预测问题。
### TRAIN TEST SPLIT ###
train_dim = int(0.7*len(df))
X_train, X_test = X[:train_dim], X[train_dim:]
y_train, y_test = Y[:train_dim], Y[train_dim:]
print(X_train.shape, y_train.shape)
print(X_test.shape, y_test.shape)
训练测试集的划分,注意先后顺序
### DEFINE PARAM GRID FOR HYPERPARM OPTIMIZATION ###
param_grid = {
'unit': [64,32],
't2v_dim': [128,64],
'lr': [1e-2,1e-3],
'act': ['elu','relu'],
'epochs': 200,
'batch_size': [512,1024]
}
### FIT T2V + LSTM ###
es = EarlyStopping(patience=5, verbose=0, min_delta=0.001, monitor='val_loss', mode='auto', restore_best_weights=True)
hypermodel = lambda x: T2V_NN(param=x, dim=sequence_length)
kgs_t2v = KerasGridSearch(hypermodel, param_grid, monitor='val_loss', greater_is_better=False, tuner_verbose=1)
kgs_t2v.set_seed(set_seed_TF2, seed=33)
kgs_t2v.search(X_train, y_train, validation_split=0.2, callbacks=[es], shuffle=False)
用一个很小众的第三方库进行调参:
//github.com/search?q=keras+hypetune
目前仅支持简单的网格搜索和随机搜索。
pred_t2v = kgs_t2v.best_model.predict(X_test).ravel()
mean_absolute_error(y_test.ravel(), pred_t2v)
最优模型的mse为

### VISUALIZE TEST PREDICTIONS ###
plt.figure(figsize=(8,5))
plt.plot(pred_t2v[:365], label='prediction')
plt.plot(y_test.ravel()[:365], label='true')
plt.title('T2V plus LSTM'); plt.legend()

然后是常规的模型:
### FIT SIMPLE LSTM ###
del param_grid['t2v_dim']
es = EarlyStopping(patience=5, verbose=0, min_delta=0.001, monitor='val_loss', mode='auto', restore_best_weights=True)
hypermodel = lambda x: NN(param=x, dim=sequence_length)
kgs = KerasGridSearch(hypermodel, param_grid, monitor='val_loss', greater_is_better=False, tuner_verbose=1)
kgs.set_seed(set_seed_TF2, seed=33)
kgs.search(X_train, y_train, validation_split=0.2, callbacks=[es], shuffle=False)

mse如上。
### VISUALIZE TEST PREDICTIONS ###
plt.figure(figsize=(8,5))
plt.plot(pred_nn[:365], label='prediction')
plt.plot(y_test.ravel()[:365], label='true')
plt.title('single LSTM'); plt.legend()

我们现在可以看一下t2v的layers的embedding效果:

可以看到,每一个序列中的每一个样本点的数据都被embedding成一个65维的向量,64维是对周期模式的embedding,还有1维是非周期维度的拟合,具体见下面的源码:
class T2V(Layer):
def __init__(self, output_dim=None, **kwargs):
self.output_dim = output_dim
super(T2V, self).__init__(**kwargs)
def build(self, input_shape):
self.W = self.add_weight(name='W',
shape=(1, self.output_dim),
initializer='uniform',
trainable=True)
self.P = self.add_weight(name='P',
shape=(1, self.output_dim),
initializer='uniform',
trainable=True)
self.w = self.add_weight(name='w',
shape=(1, 1),
initializer='uniform',
trainable=True)
self.p = self.add_weight(name='p',
shape=(1, 1),
initializer='uniform',
trainable=True)
super(T2V, self).build(input_shape)
def call(self, x):
original = self.w * x + self.p
sin_trans = K.sin(K.dot(x, self.W) + self.P)
return K.concatenate([sin_trans, original], -1)
实际上original的部分就是一个简单的线性回归模型,sin_trans是周期模型,因此可以看出t2v是认为序列 数据是由线性的非周期模式+非线性的周期模式构成的,类似于我们去做STL分解,而这里直接通过NN训练的方式,让模型学会自己去分解。
下面我们尝试一下把embedding的结果用lightgbm来训练试试,我们直接进行concat,因为每一个样本都是一个长度为24的序列,concat可以保存更多的信息。


效果很差,和文本分类中遇到的问题基本差不多,nlp有预训练模型,time2vec没有预训练模型,即使用无监督的w2v之类的来做特征提取效果也比较一般,何况这里的embedding是根据标签训练得到的,embedding的结果过拟合训练集了,所以测试集效果差也正常,不过训练集效果这么差也是醉了,在这个问题上,lgb的效果莫得LSTM来的好。
time2vec构建了文本分类和时间序列预测的桥梁,通过time2vec的思路,时间序列回归和时间序列分类(尤其是时间序列分类,非常相似)的处理方法和文本分类的思路非常相似。