coding-zzz-oe commited on
Commit
086ed9b
·
verified ·
1 Parent(s): 8b8cab4

Mirror of Khriis/PAIR

Browse files
Files changed (6) hide show
  1. README.md +129 -0
  2. config.json +4 -0
  3. cross_scorer_model.py +160 -0
  4. handler.py +114 -0
  5. reflection_scorer_weight.pt +3 -0
  6. requirements.txt +2 -0
README.md ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ library_name: transformers
3
+ pipeline_tag: text-classification
4
+ language:
5
+ - en
6
+ tags:
7
+ - reflection
8
+ - cross-encoder
9
+ - motivational-interviewing
10
+ - conversation-analysis
11
+ - roberta
12
+ ---
13
+
14
+ # PAIR Reflection Scorer (Cross‑Encoder)
15
+
16
+ This repository provides weights for a PAIR‑style cross‑encoder that scores the quality of counselor reflections in Motivational Interviewing (MI). Given a client/patient prompt and a counselor response, the model outputs a scalar score in [0,1] indicating how strongly the response reflects the prompt.
17
+
18
+ This model is based on the approach described in:
19
+
20
+ - Min, Do June; Pérez‑Rosas, Verónica; Resnicow, Kenneth; Mihalcea, Rada. “PAIR: Prompt‑Aware margIn Ranking for Counselor Reflection Scoring in Motivational Interviewing.” EMNLP 2022. https://aclanthology.org/2022.emnlp-main.11/
21
+
22
+ Please credit the authors above when using this model or derivative work.
23
+
24
+ ## Task & Motivation (from the paper)
25
+
26
+ - Reflections are a core verbal counseling skill used to convey understanding and acknowledgment of clients’ experiences.
27
+ - The goal is to automatically score counselor reflections to provide timely, useful feedback for training and education.
28
+ - Input to the scorer: a dialog turn consisting of a client prompt (likely to elicit a reflection) and the counselor’s response.
29
+ - Output: a numeric reflection score capturing the quality/strength of the reflection.
30
+
31
+ ## Method: Prompt‑Aware Margin Ranking (PAIR)
32
+
33
+ PAIR trains a prompt‑aware cross‑encoder that contrasts positive and negative (prompt, response) pairs. The key idea is to learn, for a given prompt, to rank higher‑quality reflections above lower‑quality or mismatched responses using margin‑based ranking losses.
34
+
35
+ High‑level components reflected by this implementation:
36
+
37
+ - Encoder: `roberta-base` cross‑encoder over concatenated (prompt, response).
38
+ - Scoring head: Small MLP over the [CLS] token (768 → 512 → 1) with ELU.
39
+ - Training objective (as per the paper/code): multi‑gap margin ranking that separates:
40
+ - High‑quality (HQ) reflections from medium‑quality (MQ) and low‑quality (LQ).
41
+ - HQ/MQ reflections from explicit mismatches (responses paired with the wrong prompt).
42
+ - Inference: apply sigmoid to the logit to obtain a reflection score in [0,1].
43
+
44
+ The included `cross_scorer_model.py` shows the MLP head and margin losses consistent with a PAIR‑style training setup.
45
+
46
+ ## Files
47
+
48
+ - `reflection_scorer_weight.pt` — fine‑tuned cross‑encoder weights (encoder + head).
49
+ - `cross_scorer_model.py` — `CrossScorerCrossEncoder` module used for inference/training.
50
+ - `min_pair_2022.txt` — text version summary of the PAIR paper (for reference in this repo).
51
+
52
+ ## Intended Use & Limitations
53
+
54
+ - Intended for research, education, and tooling around reflection scoring in counseling‑style conversations.
55
+ - Not a clinical or diagnostic tool; do not use for high‑stakes decisions.
56
+ - Scores are not calibrated probabilities; treat relative differences with caution.
57
+ - As with all ML models, outputs may reflect biases in pretraining/fine‑tuning data.
58
+
59
+ ## Quickstart
60
+
61
+ ```python
62
+ from huggingface_hub import hf_hub_download
63
+ from transformers import AutoModel, AutoTokenizer
64
+ import torch, importlib.util, sys
65
+
66
+ repo_id = "Khriis/PAIR" # replace if you fork
67
+
68
+ # 1) Download weights and model code from the repo
69
+ ckpt_path = hf_hub_download(repo_id=repo_id, filename="reflection_scorer_weight.pt")
70
+ code_path = hf_hub_download(repo_id=repo_id, filename="cross_scorer_model.py")
71
+
72
+ # 2) Import model definition
73
+ spec = importlib.util.spec_from_file_location("cross_scorer_model", code_path)
74
+ mod = importlib.util.module_from_spec(spec)
75
+ sys.modules["cross_scorer_model"] = mod
76
+ spec.loader.exec_module(mod)
77
+
78
+ # 3) Build encoder + head and load state dict
79
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
80
+ encoder = AutoModel.from_pretrained("roberta-base", add_pooling_layer=False)
81
+ model = mod.CrossScorerCrossEncoder(encoder).to(device)
82
+ tokenizer = AutoTokenizer.from_pretrained("roberta-base")
83
+
84
+ state = torch.load(ckpt_path, map_location=device)
85
+ sd = state.get("model_state_dict", state)
86
+ model.load_state_dict(sd)
87
+ model.eval()
88
+
89
+ # 4) Score a (prompt, response) pair
90
+ prompt = "I’ve been overwhelmed at work and can’t focus."
91
+ response = "It sounds like you’re under a lot of pressure, and it’s affecting your ability to concentrate."
92
+ batch = tokenizer(prompt, response, padding="longest", truncation=True, return_tensors="pt").to(device)
93
+ with torch.no_grad():
94
+ score = model.score_forward(**batch).sigmoid().item()
95
+ print("Reflection score:", round(score, 3))
96
+ ```
97
+
98
+ ### Using in the Toolkit
99
+
100
+ The toolkit can download the file automatically (public repo). For offline use, place `reflection_scorer_weight.pt` locally and set `REFLECTION_CKPT_PATH` to that path.
101
+
102
+ ## Citation
103
+
104
+ If you use this model or code, please cite the PAIR paper:
105
+
106
+ Informal citation: “PAIR: Prompt‑Aware margIn Ranking for Counselor Reflection Scoring in Motivational Interviewing” (Min et al., EMNLP 2022). https://aclanthology.org/2022.emnlp-main.11/
107
+
108
+ BibTeX (adapt based on official entry):
109
+
110
+ ```bibtex
111
+ @inproceedings{min-etal-2022-pair,
112
+ title = {PAIR: Prompt-Aware margIn Ranking for Counselor Reflection Scoring in Motivational Interviewing},
113
+ author = {Min, Do June and P{\'e}rez-Rosas, Ver{\'o}nica and Resnicow, Kenneth and Mihalcea, Rada},
114
+ booktitle = {Proceedings of the 2022 Conference on Empirical Methods in Natural Language Processing},
115
+ year = {2022},
116
+ url = {https://aclanthology.org/2022.emnlp-main.11/}
117
+ }
118
+ ```
119
+
120
+ Also cite RoBERTa:
121
+
122
+ ```bibtex
123
+ @misc{liu2019roberta,
124
+ title = {{RoBERTa}: A Robustly Optimized {BERT} Pretraining Approach},
125
+ author = {Liu, Yinhan and others},
126
+ year = {2019},
127
+ url = {https://arxiv.org/abs/1907.11692}
128
+ }
129
+ ```
config.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "task": "text-classification",
3
+ "custom_handler": "handler.py"
4
+ }
cross_scorer_model.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import transformers
2
+ import torch.nn as nn
3
+ from torch.nn.utils.rnn import pad_sequence
4
+ from transformers import BertTokenizer, BertModel
5
+ from transformers import AutoTokenizer, AutoModel
6
+ import torch
7
+
8
+ from transformers.modeling_outputs import MaskedLMOutput, SequenceClassifierOutput
9
+ from transformers import BertForMaskedLM
10
+
11
+ import torch.nn.functional as F
12
+
13
+ import transformers
14
+ import torch.nn as nn
15
+
16
+
17
+ class CrossScorerCrossEncoder(nn.Module):
18
+
19
+ def __init__(self, transformer):
20
+
21
+ super(CrossScorerCrossEncoder, self).__init__()
22
+
23
+ self.cross_encoder = transformer
24
+ self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
25
+
26
+
27
+ # Binary Head
28
+ self.l1 = torch.nn.Linear(768, 512)
29
+ self.relu = torch.nn.ELU()
30
+ self.l2 = torch.nn.Linear(512,1)
31
+
32
+ self.encoder_type = "cross"
33
+
34
+
35
+ def score_forward(
36
+ self,
37
+ input_ids=None,
38
+ attention_mask=None,
39
+ token_type_ids=None,
40
+ position_ids=None,
41
+ head_mask=None,
42
+ inputs_embeds=None,
43
+ encoder_hidden_states=None,
44
+ encoder_attention_mask=None,
45
+ labels=None,
46
+ output_attentions=None,
47
+ output_hidden_states=None,
48
+ return_dict=None,
49
+ return_attentions=False
50
+ ):
51
+
52
+
53
+ output = self.cross_encoder(
54
+ input_ids=input_ids,
55
+ attention_mask=attention_mask,
56
+ token_type_ids=token_type_ids,
57
+ position_ids=position_ids,
58
+ head_mask=head_mask,
59
+ inputs_embeds=inputs_embeds,
60
+ encoder_hidden_states=encoder_hidden_states,
61
+ encoder_attention_mask=encoder_attention_mask,
62
+ output_attentions=output_attentions,
63
+ output_hidden_states=output_hidden_states,
64
+ return_dict=return_dict,
65
+ )
66
+ pair_reps = output.last_hidden_state[:,0,:]
67
+ score = self.l2(self.relu(self.l1(pair_reps)))
68
+
69
+ if output_attentions and return_attentions:
70
+ return score.sigmoid().squeeze(), output.attentions
71
+
72
+ return score
73
+
74
+
75
+ def cl_loss(self, pair_scores, labels):
76
+ BSZ = pair_scores.size(0)
77
+ BSZ = int(BSZ/(4))
78
+
79
+ pair_scores= list(pair_scores.tensor_split(BSZ, dim=0) )
80
+ pair_scores = torch.stack(pair_scores)
81
+
82
+
83
+ gap_1_loss_fct = nn.MarginRankingLoss(margin=0.5)
84
+ gap_2_loss_fct = nn.MarginRankingLoss(margin=1.0)
85
+
86
+ mq_scores = pair_scores[:,1] # 1
87
+ lq_scores = pair_scores[:,2:-1] # 2
88
+
89
+
90
+ hq_scores = pair_scores[:,0]
91
+
92
+ hq_mq_loss = gap_1_loss_fct(
93
+ hq_scores.flatten(),
94
+ mq_scores.flatten(),
95
+ torch.ones(mq_scores.flatten().size()).to(self.device))
96
+ mq_lq_loss = gap_1_loss_fct(
97
+ mq_scores.repeat(1,lq_scores.size(-1)).flatten(),
98
+ lq_scores.flatten(),
99
+ torch.ones(lq_scores.flatten().size()).to(self.device))
100
+ hq_lq_loss = gap_2_loss_fct(
101
+ hq_scores.repeat(1,lq_scores.size(-1)).flatten(),
102
+ lq_scores.flatten(),
103
+ torch.ones(lq_scores.flatten().size()).to(self.device))
104
+
105
+ mismatch_scores = pair_scores[:,-1]
106
+ hq_mismatch_loss = gap_2_loss_fct(
107
+ hq_scores.flatten(),
108
+ mismatch_scores.flatten(),
109
+ torch.ones(mismatch_scores.flatten().size()).to(self.device))
110
+ mq_mismatch_loss = gap_1_loss_fct(
111
+ mq_scores.flatten(),
112
+ mismatch_scores.flatten(),
113
+ torch.ones(mismatch_scores.flatten().size()).to(self.device))
114
+ mismatch_loss = hq_mismatch_loss + mq_mismatch_loss
115
+
116
+ loss = hq_mq_loss + mq_lq_loss + hq_lq_loss + mismatch_loss
117
+ return loss
118
+
119
+ def forward(
120
+ self,
121
+ input_ids=None,
122
+ attention_mask=None,
123
+ token_type_ids=None,
124
+ position_ids=None,
125
+ head_mask=None,
126
+ inputs_embeds=None,
127
+ encoder_hidden_states=None,
128
+ encoder_attention_mask=None,
129
+ labels=None,
130
+ output_attentions=None,
131
+ output_hidden_states=None,
132
+ return_dict=None,
133
+ random = False
134
+ ):
135
+
136
+ pair_scores = self.score_forward(
137
+ input_ids=input_ids,
138
+ attention_mask=attention_mask,
139
+ token_type_ids=token_type_ids,
140
+ position_ids=position_ids,
141
+ head_mask=head_mask,
142
+ inputs_embeds=inputs_embeds,
143
+ encoder_hidden_states=encoder_hidden_states,
144
+ encoder_attention_mask=encoder_attention_mask,
145
+ labels=labels,
146
+ output_attentions=output_attentions,
147
+ output_hidden_states=output_hidden_states,
148
+ return_dict=return_dict,
149
+ ).sigmoid().squeeze()
150
+
151
+
152
+
153
+ cl_loss = self.cl_loss(pair_scores, labels)
154
+
155
+ loss = cl_loss
156
+ return SequenceClassifierOutput(
157
+ loss=loss,
158
+ logits=pair_scores,
159
+ )
160
+
handler.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import importlib.util
3
+ import sys
4
+ import os
5
+ from typing import Dict, List, Any
6
+ from transformers import AutoModel, AutoTokenizer
7
+
8
+ class EndpointHandler():
9
+ def __init__(self, path=""):
10
+ self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
11
+
12
+ # Import custom model definition from local file
13
+ # The file is expected to be in the same directory as handler.py, which 'path' points to
14
+ model_filename = "cross_scorer_model.py"
15
+ model_path = os.path.join(path, model_filename)
16
+
17
+ # Fallback if path is empty or "." and file is in CWD
18
+ if not os.path.exists(model_path):
19
+ model_path = model_filename
20
+
21
+ spec = importlib.util.spec_from_file_location("cross_scorer_model", model_path)
22
+ mod = importlib.util.module_from_spec(spec)
23
+ sys.modules["cross_scorer_model"] = mod
24
+ spec.loader.exec_module(mod)
25
+
26
+ # Initialize encoder and custom model
27
+ encoder = AutoModel.from_pretrained("roberta-base", add_pooling_layer=False)
28
+ self.model = mod.CrossScorerCrossEncoder(encoder).to(self.device)
29
+
30
+ # Load weights
31
+ weights_filename = "reflection_scorer_weight.pt"
32
+ weights_path = os.path.join(path, weights_filename)
33
+
34
+ if not os.path.exists(weights_path):
35
+ weights_path = weights_filename
36
+
37
+ state = torch.load(weights_path, map_location=self.device)
38
+ sd = state.get("model_state_dict", state)
39
+ self.model.load_state_dict(sd, strict=False)
40
+
41
+ load_res = self.model.load_state_dict(sd, strict=False)
42
+
43
+ missing = load_res.missing_keys
44
+ unexpected = load_res.unexpected_keys
45
+
46
+ # print(f"[PAIR] weights_path={weights_path}")
47
+ # print(f"[PAIR] loaded_keys={len(sd.keys())} missing_keys={len(missing)} unexpected_keys={len(unexpected)}")
48
+ # print(f"[PAIR] missing_keys_sample={missing[:20]}")
49
+ # print(f"[PAIR] unexpected_keys_sample={unexpected[:20]}")
50
+
51
+ self.model.eval()
52
+
53
+ # Initialize tokenizer
54
+ self.tokenizer = AutoTokenizer.from_pretrained("roberta-base")
55
+
56
+ def __call__(self, data: Dict[str, Any]) -> List[Dict[str, Any]]:
57
+ """
58
+ data args:
59
+ inputs (:obj: `list` | `dict`): The inputs to the model.
60
+ """
61
+ # get inputs
62
+ inputs = data.pop("inputs", data)
63
+
64
+ # If inputs is a dict (single item), wrap in list to reuse logic, or handle list
65
+ if isinstance(inputs, dict):
66
+ inputs = [inputs]
67
+
68
+ results = []
69
+ for item in inputs:
70
+ prompt = item.get("prompt")
71
+ response = item.get("response")
72
+
73
+ if not prompt or not response:
74
+ results.append({"error": "Missing prompt or response"})
75
+ continue
76
+
77
+ # Preprocessing
78
+ prompt_fmt = f"Client: {prompt}"
79
+ response_fmt = f"Therapist: {response}"
80
+ batch = self.tokenizer(prompt_fmt, response_fmt, padding="longest", truncation=True, return_tensors="pt").to(self.device)
81
+
82
+ # Inference
83
+ with torch.no_grad():
84
+ # score_forward returns raw logits (based on README/code usage), we need sigmoid
85
+ score = self.model.score_forward(**batch).sigmoid().item()
86
+
87
+ # Preprocessing (A: prompt->response)
88
+ batch_a = self.tokenizer(
89
+ prompt, response,
90
+ padding="longest", truncation=True, return_tensors="pt"
91
+ ).to(self.device)
92
+
93
+ # Preprocessing (B: response->prompt)
94
+ batch_b = self.tokenizer(
95
+ response, prompt,
96
+ padding="longest", truncation=True, return_tensors="pt"
97
+ ).to(self.device)
98
+
99
+ with torch.no_grad():
100
+ logit_a = self.model.score_forward(**batch_a).item()
101
+ prob_a = torch.sigmoid(torch.tensor(logit_a)).item()
102
+
103
+ logit_b = self.model.score_forward(**batch_b).item()
104
+ prob_b = torch.sigmoid(torch.tensor(logit_b)).item()
105
+
106
+ # print(f"[PAIR] A(prompt->resp) logit={logit_a:.6f} prob={prob_a:.6f}")
107
+ # print(f"[PAIR] B(resp->prompt) logit={logit_b:.6f} prob={prob_b:.6f}")
108
+
109
+ # TEMP: return the higher one to validate quickly
110
+ score = max(prob_a, prob_b)
111
+
112
+ results.append({"score": score})
113
+
114
+ return results
reflection_scorer_weight.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:81eb3a3a9240b9bc05b7ca79b1e7920045917504965c7233fbc32e0c3b99ebf5
3
+ size 497885869
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ transformers
2
+ torch