process-fm / inference.py
Polifemus's picture
Add standalone inference
d98c605 verified
Raw History Blame Contribute Delete
6.33 kB
"""Standalone inference for Process-FM checkpoints (fm-v6 and scaling/sc*).
Dependency: torch + numpy only. The models use global activity/resource ids:
activities 4..776 (UNK=1), resources 2..2739 (UNK=1), log ids from vocab["log_ids"].
"""
import json
from datetime import datetime
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
N_NUM = 6
ACT_OFFSET = 4
RES_OFFSET = 2
UNK_ACT = 1
UNK_RES = 1
class EventTransformer(nn.Module):
def __init__(self, n_act: int, n_res: int, n_logs: int, n_classes: int,
d_model: int = 256, n_layers: int = 6, n_heads: int = 8,
dropout: float = 0.1, max_len: int = 256,
act_dim: int = 64, res_dim: int = 32, log_dim: int = 16):
super().__init__()
self.act_emb = nn.Embedding(n_act, act_dim, padding_idx=0)
self.res_emb = nn.Embedding(n_res, res_dim, padding_idx=0)
self.log_emb = nn.Embedding(n_logs, log_dim)
self.proj = nn.Linear(act_dim + res_dim + log_dim + N_NUM, d_model)
self.pos = nn.Embedding(max_len, d_model)
layer = nn.TransformerEncoderLayer(
d_model=d_model, nhead=n_heads, dim_feedforward=4 * d_model,
dropout=dropout, batch_first=True, norm_first=True, activation="gelu",
)
self.encoder = nn.TransformerEncoder(layer, num_layers=n_layers)
self.norm = nn.LayerNorm(d_model)
self.act_out = nn.Linear(d_model, n_classes)
self.res_out = nn.Linear(d_model, n_res)
self.max_len = max_len
def forward(self, gact: torch.Tensor, gres: torch.Tensor, num: torch.Tensor,
log_id: torch.Tensor, causal: bool = True,
return_hidden: bool = False):
b, t = gact.shape
x = torch.cat([
self.act_emb(gact),
self.res_emb(gres),
self.log_emb(log_id).unsqueeze(1).expand(b, t, -1),
num,
], dim=-1)
x = self.proj(x)
idx = torch.arange(t, device=gact.device).unsqueeze(0).expand(b, t)
x = x + self.pos(idx)
if causal:
x = self.encoder(x, mask=torch.triu(
torch.ones(t, t, device=gact.device, dtype=torch.bool), diagonal=1))
else:
x = self.encoder(x)
h = self.norm(x)
if return_hidden:
return h
return self.act_out(h), self.res_out(h)
def build_model(config: dict) -> EventTransformer:
return EventTransformer(
n_act=config["n_act"], n_res=config["n_res"], n_logs=config["n_logs"],
n_classes=config["n_classes"], d_model=config["d_model"],
n_layers=config["n_layers"], n_heads=config["n_heads"],
dropout=config["dropout"], max_len=config["max_len"],
act_dim=config["act_dim"], res_dim=config["res_dim"], log_dim=config["log_dim"],
)
def _to_ns(ts) -> int:
if isinstance(ts, datetime):
return int(ts.timestamp() * 1e9)
return int(np.datetime64(ts, "ns").astype("int64"))
def featurize(events: list[dict], vocab: dict, amount: float = 0.0,
log: str | None = None):
max_len = min(int(vocab.get("max_prefix_len", 256)), 256)
events = events[-max_len:]
n = len(events)
ts = np.array([_to_ns(e["timestamp"]) for e in events], dtype=np.int64)
dt_prev = np.zeros(n, dtype=np.float64)
dt_prev[1:] = (ts[1:] - ts[:-1]) / 1e9
dt_start = (ts - ts[0]) / 1e9
hours = ((ts // 3_600_000_000_000) % 24).astype(np.float64)
weekday = ((ts // 86_400_000_000_000) + 3) % 7
amount_feat = np.log1p(max(amount, 0.0)) / 14.0
num = np.stack([
np.log1p(dt_prev) / 20.0,
np.log1p(dt_start) / 20.0,
np.sin(2 * np.pi * hours / 24.0),
np.cos(2 * np.pi * hours / 24.0),
weekday / 6.0,
np.full(n, amount_feat),
], axis=1).astype(np.float32)
act_ids = {str(a): ACT_OFFSET + i for i, a in enumerate(vocab["activities"])}
res_ids = {str(r): RES_OFFSET + i for i, r in enumerate(vocab["resources"])}
gact = np.array([act_ids.get(str(e.get("activity", "")), UNK_ACT) for e in events],
dtype=np.int64)
gres = np.array([res_ids.get(str(e.get("resource", "")), UNK_RES) for e in events],
dtype=np.int64)
log_name = log or vocab.get("default_log")
log_id = int(vocab["log_ids"].get(log_name, vocab.get("log_unk_id", vocab["n_logs"] - 1)))
return (
torch.from_numpy(gact)[None],
torch.from_numpy(gres)[None],
torch.from_numpy(num)[None],
torch.tensor([log_id], dtype=torch.long),
)
def load_model(release_dir: str | Path = "."):
release_dir = Path(release_dir)
config = json.loads((release_dir / "config.json").read_text())
vocab = json.loads((release_dir / "vocab.json").read_text())
model = build_model(config)
state = torch.load(release_dir / "model.pt", map_location="cpu", weights_only=True)
model.load_state_dict(state)
model.eval()
return model, vocab
@torch.no_grad()
def predict(model: EventTransformer, vocab: dict, events: list[dict],
amount: float = 0.0, top_k: int = 5, log: str | None = None) -> list[tuple[str, float]]:
gact, gres, num, log_id = featurize(events, vocab, amount=amount, log=log)
h = model(gact, gres, num, log_id, causal=True, return_hidden=True)
logits = model.act_out(h[0, gact.shape[1] - 1].float())
probs = torch.softmax(logits, dim=-1)
k = min(top_k, probs.numel() - 1)
values, indices = torch.topk(probs, k)
id_to_act = {ACT_OFFSET + i: str(a) for i, a in enumerate(vocab["activities"])}
return [(id_to_act.get(int(i), "UNK"), float(v)) for v, i in zip(values, indices)]
if __name__ == "__main__":
here = Path(__file__).resolve().parent
model, vocab = load_model(here / "fm-v6")
activities = [str(a) for a in vocab["activities"][:3]]
resources = [str(r) for r in vocab["resources"][:2]]
events = [
{"activity": activities[0], "resource": resources[0], "timestamp": "2024-01-01 10:00:00"},
{"activity": activities[1], "resource": resources[1], "timestamp": "2024-01-01 11:00:00"},
{"activity": activities[2], "resource": resources[0], "timestamp": "2024-01-01 12:00:00"},
]
print(predict(model, vocab, events, top_k=3, log=vocab.get("default_log")))