LIJINGHAI111's picture
upload self_train.py
220c07d verified
Raw History Blame Contribute Delete
16.3 kB
"""主动自我训练(用户数据 → 拆 train/test → 精英保留 + 模型回滚)
策略:
1. 每轮同时采样模型生成 + 随机生成候选
2. 在 train 集上评估 R²,跨轮保留精英候选(elite pool)
3. 只对 R² > 0.1 的正向候选做伪标签 CE 微调(避免强化错误模式)
4. REINFORCE 策略梯度用于模型采样的候选
5. 保存每轮开始前的模型 state,如果本轮最佳 R² 下降则回滚
6. 终止条件:R² > 0.995 或 连续 4 轮 test R² 无改善
"""
import warnings
import sys
import numpy as np
import torch
import torch.nn.functional as F
import config
from vocab import Vocab
from model import SymbolicRegressionModel
from data_generator import RandomExpressionGenerator
# 关键:stdout 行缓冲,保证 print() 立即刷新。
# 否则在后台/管道运行时(python 默认块缓冲),进度会攒在一旁,
# 直到进程结束才一次性输出 —— 这正是"训练过程不输出"的根因。
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(line_buffering=True)
# 全局静音候选表达式在数值评估时的 numpy 警告(exp/sqrt/log/power 溢出、
# 定义域外、除零等均属常规现象,结果已用 isfinite/complex 过滤)。
# 全局设置可覆盖 lambdify 生成的已编译模块,避免每一轮都刷屏。
np.seterr(over="ignore", invalid="ignore", divide="ignore")
warnings.filterwarnings("ignore", module="lambdifygenerated")
COPIED_MODEL_STATE_KEY = "_saved_lstm_state"
def train_test_split_arrays(X, y, test_size=0.3, seed=None):
"""手写随机 train/test 拆分。"""
rng = np.random.RandomState(seed)
n = X.shape[0]
idx = rng.permutation(n)
n_te = max(1, int(test_size * n))
te_idx, tr_idx = idx[:n_te], idx[n_te:]
return X[tr_idx], X[te_idx], y[tr_idx], y[te_idx]
def eval_expr_on_data(expr_str: str, X: np.ndarray, y_true: np.ndarray) -> tuple:
"""计算给定表达式在数据上的预测值和 MSE。无效返回 NaN。"""
import sympy as sp
import warnings
D = X.shape[1]
try:
expr = sp.sympify(expr_str)
symbols = [sp.Symbol(f"x{i}") for i in range(D)]
f = sp.lambdify(symbols, expr, modules="numpy")
# 候选表达式常落在定义域外或数值溢出(exp/sqrt/log/power),
# 静音这些 numpy RuntimeWarning,避免刷屏。结果 NaN/inf 会被下方过滤。
with warnings.catch_warnings():
warnings.simplefilter("ignore")
with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
y_pred = f(*[X[:, i] for i in range(D)])
if np.iscomplexobj(y_pred) or not np.isfinite(y_pred).all():
return np.full_like(y_true, np.nan), np.nan
mse = float(np.nanmean((y_pred - y_true) ** 2))
return y_pred, mse
except Exception:
return np.full_like(y_true, np.nan), np.nan
def _generate_random_candidates(num_candidates: int, D: int, seed: int = None) -> list[str]:
"""生成随机候选,混合深度。"""
gen = RandomExpressionGenerator(seed=seed)
rng = np.random.RandomState(seed)
candidates = []
depths = [2, 3, 4]
while len(candidates) < num_candidates * 3:
try:
depth = rng.choice(depths)
data = gen.generate(var_count=D, max_depth=int(depth))
candidates.append(data["expr_str"])
except Exception:
continue
return candidates[:num_candidates]
def _evaluate_candidates(expr_strings: list[str], X: np.ndarray, y: np.ndarray,
y_var: float) -> tuple:
"""批量评估候选,返回 rewards (R²) 和 mses。无效 reward=-1。"""
n = len(expr_strings)
rewards = np.full(n, -1.0)
mses = np.full(n, np.nan)
for i, expr_str in enumerate(expr_strings):
_, mse = eval_expr_on_data(expr_str, X, y)
mses[i] = mse
if np.isfinite(mse):
rewards[i] = 1.0 - mse / max(y_var, 1e-9)
return rewards, mses
@torch.no_grad()
def _encode_input(X: np.ndarray, y: np.ndarray, device: str):
"""编码用户数据为模型输入 [1, N, D_MAX], [1, N]."""
D = X.shape[1]
N = X.shape[0]
X_t = torch.zeros(1, N, config.D_MAX, dtype=torch.float32, device=device)
X_t[0, :, :D] = torch.from_numpy(X).to(device)
y_t = torch.from_numpy(y).float().unsqueeze(0).to(device)
n_samples = torch.tensor([N], device=device)
var_counts = torch.tensor([D], device=device)
return X_t, y_t, n_samples, var_counts
@torch.enable_grad()
def _sample_model(model, X_t, y_t, n_samples, var_counts, device,
max_len=None, num_candidates=20, greedy=False, temperature=1.5):
"""从模型采样候选。greedy → argmax;否则多项式采样 + 噪声扰动。"""
if max_len is None:
max_len = config.T_MAX
sample_size = num_candidates
vocab = model.vocab
z = model.encoder(X_t, y_t, n_samples, var_counts)
if not greedy:
noise_scale = 0.15
z = z + torch.randn_like(z) * noise_scale
temp = temperature
else:
temp = 0.1
hidden = z.unsqueeze(0).expand(-1, sample_size, -1).contiguous()
cell = z.unsqueeze(0).expand(-1, sample_size, -1).contiguous()
if not greedy:
hidden = hidden + torch.randn_like(hidden) * noise_scale
cell = cell + torch.randn_like(cell) * noise_scale
token = torch.full((sample_size, 1), fill_value=vocab.sos_id, device=device)
sequences = [token]
log_probs = []
done = torch.zeros(sample_size, dtype=torch.bool, device=device)
for _ in range(max_len):
embedded = model.lstm.embedding(token)
output, (hidden, cell) = model.lstm.lstm(embedded, (hidden, cell))
logits = model.lstm.proj(output).squeeze(1)
logits = logits / temp
if greedy:
token = logits.argmax(dim=-1, keepdim=True)
else:
log_probs_t = F.log_softmax(logits, dim=-1)
probs_t = F.softmax(logits, dim=-1)
dist = torch.distributions.Categorical(probs=probs_t)
token = dist.sample().unsqueeze(-1)
lp = torch.gather(log_probs_t, 1, token)
lp = lp.masked_fill(done.unsqueeze(-1), 0.0)
log_probs.append(lp)
sequences.append(token)
done = done | (token.squeeze(-1) == vocab.eos_id)
if done.all():
break
tokens = torch.cat(sequences, dim=1)
if not greedy:
log_probs = torch.cat(log_probs, dim=1)
tokens = tokens[:, 1:]
expr_strings = []
for i in range(sample_size):
toks = tokens[i]
if (toks == vocab.eos_id).any():
eos = (toks == vocab.eos_id).nonzero(as_tuple=True)[0][0].item()
toks = toks[:eos]
expr_strings.append(vocab.decode(toks.tolist()))
return tokens, log_probs if not greedy else None, expr_strings
def _save_model_snapshot(model):
"""保存 LSTM 参数快照到 model 的临时属性。"""
sd = {k: v.data.clone() for k, v in model.lstm.state_dict().items()}
setattr(model, COPIED_MODEL_STATE_KEY, sd)
def _restore_model_snapshot(model):
"""从快照恢复 LSTM 参数。"""
sd = getattr(model, COPIED_MODEL_STATE_KEY, None)
if sd is not None:
for k, v in sd.items():
model.lstm.state_dict()[k].data.copy_(v)
def _encode_expr(expr_str: str, vocab: Vocab, device: str):
"""把表达式字符串编码为 token 序列。"""
token_ids = vocab.encode(expr_str)
token_ids = [vocab.sos_id] + token_ids + [vocab.eos_id]
token_ids = token_ids[:config.T_MAX]
return torch.tensor(token_ids, dtype=torch.long, device=device).unsqueeze(0)
def self_train_on_user_data(model, X, y, device, split=0.7,
max_rounds=15, candidates=96, seed=None,
verbose=True):
"""针对用户数据 (X, y) 执行主动自我训练。
返回 dict {best_expr, best_r2, converged, rounds, results, round_results}
"""
vocab = model.vocab
if X.ndim == 1:
X = X.reshape(-1, 1)
D = X.shape[1]
Xtr, Xte, ytr, yte = train_test_split_arrays(
X, y, test_size=1 - split, seed=seed
)
ytr_var = float(np.var(ytr))
yte_var = float(np.var(yte))
if verbose:
print(f" [数据] 训练集: {Xtr.shape[0]} 样本, 测试集: {Xte.shape[0]} 样本, 维度 D={D}")
print(f" [数据] y 方差: train={ytr_var:.4f}, test={yte_var:.4f}")
Xtr_t, ytr_t, ntr, vctr = _encode_input(Xtr, ytr, device)
optimizer = torch.optim.Adam(model.lstm.parameters(), lr=3e-4)
# ---- 精英池:跨轮保留历史最佳候选 ----
elite_pool = []
PATIENCE = 4
results = []
best_r2 = -float("inf")
best_expr = None
no_improve = 0
converged = False
for rnd in range(1, max_rounds + 1):
model.train()
# --- 保存本轮开始前的模型 ---
_save_model_snapshot(model)
# --- 温度退火 ---
temp = max(1.2, 3.0 - 0.18 * (rnd - 1))
# --- 生成候选 ---
n_model = int(candidates * 0.45)
n_rand = candidates - n_model
# 模型采样
tokens, log_probs, model_exprs = _sample_model(
model, Xtr_t, ytr_t, ntr, vctr, device,
num_candidates=n_model, greedy=False, temperature=temp
)
# 随机生成(混合深度,提供多样性)
rand_candidates = _generate_random_candidates(
n_rand, D, seed=(seed or 0) + rnd * 131 + 7
)
# 加入精英池中的候选(去重后按 R² 排序取前10)
elite_exprs = [e for e, _ in elite_pool[:10]]
expr_strings = list(model_exprs) + list(rand_candidates) + elite_exprs
# --- 评估候选 ---
rewards, mses = _evaluate_candidates(expr_strings, Xtr, ytr, ytr_var)
valid_mask = np.isfinite(mses)
valid_idxs = np.where(valid_mask)[0]
if valid_idxs.size == 0:
if verbose:
print(f" [自我训练] round {rnd}/{max_rounds} | T={temp:.2f} | 所有候选无效,跳过此轮")
results.append({"round": rnd, "train_r2": -1.0, "test_r2": -1.0,
"test_expr": "", "n_valid": 0})
continue
best_local_idx = valid_idxs[int(np.argmax(rewards[valid_idxs]))]
best_local_expr = expr_strings[best_local_idx]
best_local_r2 = float(rewards[best_local_idx])
# --- 更新精英池 ---
for i in valid_idxs:
r2 = float(rewards[i])
if r2 > 0.05:
elite_pool.append((expr_strings[i], r2))
elite_pool = sorted(set(elite_pool), key=lambda x: -x[1])[:20]
# --- 训练步:伪标签 + REINFORCE ---
# 只对 R² > 0.1 的正向候选做 CE 伪标签
good_mask = rewards > 0.1
good_idxs = np.where(good_mask)[0]
ce_losses = []
for idx in good_idxs:
expr_str = expr_strings[idx]
if expr_str == "":
continue
try:
t_tensor = _encode_expr(expr_str, vocab, device)
z = model.encoder(Xtr_t, ytr_t, ntr, vctr)
hidden = z.unsqueeze(0)
cell = z.unsqueeze(0)
logits, _, _ = model.lstm(t_tensor[:, :-1], hidden, cell)
targets = t_tensor[:, 1:]
ce = F.cross_entropy(logits.reshape(-1, vocab.vocab_size), targets.reshape(-1))
ce_losses.append(ce * 0.3)
except Exception:
continue
if ce_losses and verbose:
pos_count = len(ce_losses)
best_r2_among_pos = max(rewards[good_idxs])
print(f" └─ 伪标签: {pos_count} 个正向候选 (R²>{0.1:.1f}, 最高={best_r2_among_pos:.3f})")
# REINFORCE:仅对模型采样候选(前 n_model 个)中有效且导通的
reinforce_loss = None
model_valid_idx = valid_idxs[valid_idxs < n_model]
non_collapsed = []
for idx in model_valid_idx:
if idx < n_model and log_probs is not None:
lp_row = log_probs[idx]
if lp_row.numel() > 0 and lp_row.sum() < -0.01:
non_collapsed.append(idx)
if len(non_collapsed) >= 3:
m_rewards = rewards[non_collapsed]
m_rewards = np.where(np.isfinite(m_rewards), m_rewards, -1.0)
m_advantage_t = torch.tensor(m_rewards - m_rewards.mean(),
dtype=torch.float32, device=device)
m_logp = log_probs[non_collapsed]
m_logp_sum = m_logp.sum(dim=-1)
reinforce_loss = -(m_advantage_t * m_logp_sum).mean()
if ce_losses or reinforce_loss is not None:
all_losses = ce_losses[:]
if reinforce_loss is not None:
all_losses.append(reinforce_loss * 0.5)
loss = torch.stack(all_losses).mean()
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.lstm.parameters(), 1.0)
optimizer.step()
# --- test 评估 ---
te_expr = best_local_expr
_, te_mse = eval_expr_on_data(te_expr, Xte, yte)
if np.isfinite(te_mse):
te_r2 = 1.0 - te_mse / max(yte_var, 1e-9)
else:
te_r2 = -float("inf")
# --- 模型回滚:如果本轮最佳 R² 比上一轮差,恢复模型 ---
prev_r2 = results[-1]["best_local_r2"] if results else -float("inf")
if best_local_r2 < prev_r2 - 0.1 and len(results) >= 1:
_restore_model_snapshot(model)
if verbose:
print(f" └─ 模型回滚(best R² {best_local_r2:.3f} < 上一轮 {prev_r2:.3f})")
# 重新用恢复后的模型采样一次作为补充
tokens2, lp2, model_exprs2 = _sample_model(
model, Xtr_t, ytr_t, ntr, vctr, device,
num_candidates=n_model, greedy=False, temperature=temp
)
expr_strings2 = list(model_exprs2) + elite_exprs[:5]
rewards2, mses2 = _evaluate_candidates(expr_strings2, Xtr, ytr, ytr_var)
vm2 = np.isfinite(mses2)
vi2 = np.where(vm2)[0]
if vi2.size > 0:
best_i2 = vi2[int(np.argmax(rewards2[vi2]))]
best_local_expr = expr_strings2[best_i2]
best_local_r2 = float(rewards2[best_i2])
_, te_mse2 = eval_expr_on_data(best_local_expr, Xte, yte)
if np.isfinite(te_mse2):
te_r2 = 1.0 - te_mse2 / max(yte_var, 1e-9)
else:
te_r2 = -float("inf")
if te_r2 > best_r2:
best_r2 = te_r2
best_expr = te_expr
no_improve = 0
else:
no_improve += 1
nv = int(valid_mask.sum())
results.append({
"round": rnd, "train_r2": float(np.nanmean(rewards)),
"test_r2": float(te_r2), "test_expr": te_expr,
"best_local_expr": best_local_expr,
"best_local_r2": best_local_r2, "n_valid": nv,
"n_good_ce": len(ce_losses),
})
if verbose:
avg_r2 = float(np.nanmean(rewards))
print(f" [自我训练] round {rnd}/{max_rounds} | T={temp:.2f} | "
f"valid={nv}/{candidates} | "
f"avg R²={avg_r2:.4f} | "
f"best R²={best_local_r2:.4f} | "
f"test R²={te_r2:.4f} | {te_expr}")
# --- 终止判定 ---
if te_r2 > 0.995:
if verbose:
print(f" → R² 达标 ({te_r2:.4f}),停止自我训练 ✓")
converged = True
break
if no_improve >= PATIENCE:
if verbose:
print(f" → test R² 连续 {PATIENCE} 轮无改善,停止自我训练(最佳: {best_r2:.4f})")
converged = True
break
return {
"best_expr": best_expr,
"best_r2": best_r2,
"converged": converged,
"rounds": len(results),
"round_results": results,
}