ashish296 commited on
Commit
46bfb9c
·
verified ·
1 Parent(s): 78ab97e

Make repo self-contained: add pulse_model.py + runnable usage example

Browse files
Files changed (2) hide show
  1. README.md +49 -16
  2. pulse_model.py +113 -0
README.md CHANGED
@@ -96,24 +96,57 @@ intended use complies with the underlying dataset licenses.** A cleanly-licensed
96
 
97
  ## Usage
98
 
 
 
 
 
 
 
 
99
  ```python
100
  import torch
101
- from pulse.model import PulseConfig, PulseUnified, load_tokenizer
102
-
103
- blob = torch.load("pulse_decide_150m.pt", map_location="cpu", weights_only=True)
104
- cfg = PulseConfig(**blob["cfg"])
105
- tok = load_tokenizer(cfg)
106
- model = PulseUnified(cfg); model.load_state_dict(blob["state_dict"]); model.eval()
107
-
108
- state = "My card was declined at the store this morning."
109
- choices = ["billing", "fraud", "technical", "account_access"]
110
-
111
- first = [f"{state} [SEP] Which category applies?"] * len(choices)
112
- enc = tok(first, choices, truncation=True, max_length=cfg.max_length,
113
- padding=True, return_tensors="pt")
114
- with torch.no_grad():
115
- scores = model.score_pairs(enc["input_ids"], enc["attention_mask"])
116
- print(choices[int(scores.argmax())])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  ```
118
 
119
  ## Model family
 
96
 
97
  ## Usage
98
 
99
+ The repo is self-contained: `pulse_model.py` is the full model definition (113 lines, only
100
+ `torch` + `transformers` required).
101
+
102
+ ```bash
103
+ pip install torch transformers
104
+ ```
105
+
106
  ```python
107
  import torch
108
+ from pulse_model import PulseConfig, PulseUnified, load_tokenizer
109
+
110
+ blob = torch.load("pulse_decide_150m.pt", map_location="cpu", weights_only=True)
111
+ cfg = PulseConfig(**blob["cfg"])
112
+ tok = load_tokenizer(cfg)
113
+ model = PulseUnified(cfg)
114
+ model.load_state_dict(blob["state_dict"])
115
+ model.eval()
116
+
117
+ def decide(state, question, choices, context=None):
118
+ """Score every choice and return (label, confidence). Options are read at RUNTIME,
119
+ so label sets never seen in training still work."""
120
+ ctx = " | ".join(context) + " || " if context else ""
121
+ left = [f"{ctx}{state} [SEP] {question}"] * len(choices)
122
+ enc = tok(left, choices, truncation=True, max_length=cfg.max_length,
123
+ padding=True, return_tensors="pt")
124
+ with torch.no_grad():
125
+ scores = model.score_pairs(enc["input_ids"], enc["attention_mask"])
126
+ probs = torch.softmax(scores, dim=0)
127
+ i = int(probs.argmax())
128
+ return choices[i], float(probs[i])
129
+
130
+ label, confidence = decide(
131
+ state="We were invoiced twice this month and the NET-60 terms were not applied.",
132
+ question="Which team should handle this?",
133
+ choices=["sales", "support", "billing", "success", "compliance"],
134
+ context=["NET-60 payment terms", "prior billing dispute on file"],
135
+ )
136
+ print(label, f"{confidence:.1%}") # -> billing 99.7%
137
+
138
+ # Governance: act only when confident, otherwise escalate.
139
+ if confidence < 0.60:
140
+ print("escalate — not confident enough to act")
141
+ ```
142
+
143
+ Download the two files you need:
144
+
145
+ ```python
146
+ from huggingface_hub import hf_hub_download
147
+ repo = "RouterML/pulse-decide-150m"
148
+ ckpt = hf_hub_download(repo, "pulse_decide_150m.pt")
149
+ code = hf_hub_download(repo, "pulse_model.py")
150
  ```
151
 
152
  ## Model family
pulse_model.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pulse Unified DDM — the model (question-conditioned cross-encoder).
2
+
3
+ CORRECTION (2026-09-24): the model now READS each typed question. It does not use
4
+ fixed positional heads. For a CHOICE/BOOLEAN question it scores every declared
5
+ choice by encoding `state [SEP] question [SEP] choice`; for a SCORE question it
6
+ encodes `state [SEP] question`. So the same model answers arbitrary typed
7
+ questions with arbitrary choice sets — this is the general Pulse contract, not a
8
+ triage classifier.
9
+
10
+ "One unified model invocation" = one logical call. Internally it encodes several
11
+ short sequences (one per (question,choice) pair, one per (state,plan) pair) and
12
+ batches them; it is non-autoregressive and generates no text.
13
+
14
+ Compute: default encoder is SMALL, to validate the full pipeline first on the
15
+ 64 GB MLX Mac via torch's MPS backend. The production encoder is chosen by
16
+ measured quality/calibration/latency/memory/license in DownshiftEval — NOT copied
17
+ from any other project.
18
+
19
+ Requires torch + transformers (use a supported Python environment; the system
20
+ Python 3.14 environment does not currently provide the required torch wheel).
21
+ Status: EXPERIMENTAL BASELINE. Instantiating this class creates pretrained
22
+ encoder weights plus newly initialized Pulse heads; a trained Banking77
23
+ checkpoint exists at ``checkpoints/pulse_banking77.pt`` and must be loaded
24
+ explicitly. That checkpoint is not an SCPO or general OutcomeLM model.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+
30
+ try:
31
+ import torch
32
+ from torch import nn
33
+ from transformers import AutoModel, AutoTokenizer
34
+ _HAVE_TORCH = True
35
+ except Exception: # pragma: no cover
36
+ _HAVE_TORCH = False
37
+ nn = object # type: ignore
38
+
39
+
40
+ @dataclass
41
+ class PulseConfig:
42
+ # SMALL default for first end-to-end pipeline validation (~22M params, 384 hid).
43
+ # Swap up (and re-measure) via DownshiftEval; do not hardcode a "winner".
44
+ encoder_name: str = "sentence-transformers/all-MiniLM-L6-v2"
45
+ hidden_size: int = 384
46
+ max_length: int = 256
47
+ dropout: float = 0.1
48
+ model_version: str = "pulse-1-unified-0.0.0-experimental"
49
+
50
+
51
+ if _HAVE_TORCH:
52
+
53
+ class PulseUnified(nn.Module):
54
+ """Shared cross-encoder + scalar scorers.
55
+
56
+ Public scoring API (used by trainer, evaluator, planner):
57
+ score_pairs(seq_ids, seq_mask) -> (N,) raw scalar scores
58
+ The collator builds the sequences; grouping (below) turns per-choice
59
+ scores into per-question distributions.
60
+ """
61
+
62
+ def __init__(self, cfg: PulseConfig) -> None:
63
+ super().__init__()
64
+ self.cfg = cfg
65
+ self.encoder = AutoModel.from_pretrained(cfg.encoder_name)
66
+ h = cfg.hidden_size
67
+ self.drop = nn.Dropout(cfg.dropout)
68
+ # one scalar scorer reused for choice-scoring AND score-questions;
69
+ # a separate abstain scorer; a separate policy scorer.
70
+ self.value_head = nn.Linear(h, 1) # choice score / SCORE logit
71
+ self.abstain_head = nn.Linear(h, 1) # per (state,question) abstain logit
72
+ self.policy_head = nn.Sequential(
73
+ nn.Linear(h, h // 2), nn.GELU(), nn.Linear(h // 2, 1)
74
+ )
75
+
76
+ def _pool(self, input_ids, attention_mask):
77
+ out = self.encoder(input_ids=input_ids, attention_mask=attention_mask)
78
+ mask = attention_mask.unsqueeze(-1).float()
79
+ pooled = (out.last_hidden_state * mask).sum(1) / mask.sum(1).clamp(min=1e-6)
80
+ return self.drop(pooled)
81
+
82
+ def score_pairs(self, seq_ids, seq_mask):
83
+ """Raw scalar per input sequence (state+question+choice, or state+question)."""
84
+ return self.value_head(self._pool(seq_ids, seq_mask)).squeeze(-1)
85
+
86
+ def abstain_logits(self, seq_ids, seq_mask):
87
+ return self.abstain_head(self._pool(seq_ids, seq_mask)).squeeze(-1)
88
+
89
+ def policy_scores(self, seq_ids, seq_mask):
90
+ """Raw scalar per (state, plan) sequence; planner ranks by this."""
91
+ return self.policy_head(self._pool(seq_ids, seq_mask)).squeeze(-1)
92
+
93
+ @staticmethod
94
+ def grouped_softmax(scores, groups): # type: ignore
95
+ """Turn flat per-choice `scores` into per-question distributions.
96
+ `groups` is a list of (start, end) slices, one per CHOICE/BOOLEAN
97
+ question, with variable choice counts. Returns list of prob tensors."""
98
+ return [torch.softmax(scores[s:e], dim=0) for (s, e) in groups]
99
+
100
+ def load_tokenizer(cfg: PulseConfig):
101
+ return AutoTokenizer.from_pretrained(cfg.encoder_name)
102
+
103
+ else: # pragma: no cover
104
+
105
+ class PulseUnified: # type: ignore
106
+ def __init__(self, *_: object, **__: object) -> None:
107
+ raise RuntimeError(
108
+ "torch/transformers not installed. Create a Python 3.12 venv and "
109
+ "`pip install -r pulse/requirements.txt` (MPS works on the MLX Mac)."
110
+ )
111
+
112
+ def load_tokenizer(_cfg): # type: ignore
113
+ raise RuntimeError("torch/transformers not installed.")