pear-mt 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pear/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """PEAR-MT: pairwise QE metrics for machine translation evaluation."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from pear.inference import (
6
+ build_pairwise_samples,
7
+ build_reference_anchored_samples,
8
+ load_metric,
9
+ score_pairwise,
10
+ score_reference_anchored,
11
+ )
12
+ from pear.mbr import pear_utility_matrix, select_mbr_hypothesis
13
+ from pear.models import download_model, load_from_checkpoint
14
+
15
+ try:
16
+ __version__ = version("pear-mt")
17
+ except PackageNotFoundError:
18
+ __version__ = "0+unknown"
19
+
20
+ __all__ = [
21
+ "__version__",
22
+ "build_pairwise_samples",
23
+ "build_reference_anchored_samples",
24
+ "download_model",
25
+ "load_from_checkpoint",
26
+ "load_metric",
27
+ "pear_utility_matrix",
28
+ "score_pairwise",
29
+ "score_reference_anchored",
30
+ "select_mbr_hypothesis",
31
+ ]
@@ -0,0 +1 @@
1
+ """Console entry points for PEAR."""
pear/commands/main.py ADDED
@@ -0,0 +1,214 @@
1
+ """Command line interface for PEAR."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from pear import __version__
11
+ from pear.inference import load_metric, score_pairwise, score_reference_anchored
12
+ from pear.mbr import pear_utility_matrix, select_mbr_hypothesis
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(description="Run PEAR MT metric inference.")
17
+ parser.add_argument(
18
+ "--version", action="version", version=f"%(prog)s {__version__}"
19
+ )
20
+ sub = parser.add_subparsers(dest="command", required=True)
21
+
22
+ score = sub.add_parser("score", help="Score TSV rows with src/mt_0/mt_1 columns.")
23
+ _add_model_args(score)
24
+ score.add_argument("--input", type=Path, required=True)
25
+ score.add_argument("--output", type=Path)
26
+ score.add_argument("--mode", choices=["pairwise", "reference"], default="pairwise")
27
+ score.add_argument("--both", action="store_true", help="Score both pair orders.")
28
+ score.add_argument(
29
+ "--batch-size",
30
+ type=int,
31
+ default=8,
32
+ help="Number of pairwise examples per PEAR forward batch.",
33
+ )
34
+ score.add_argument(
35
+ "--gpus",
36
+ type=_parse_gpus,
37
+ default="auto",
38
+ help=(
39
+ "GPU usage: auto uses one available CUDA/MPS device, 0 forces CPU, "
40
+ "and 1 forces one accelerator device."
41
+ ),
42
+ )
43
+
44
+ mbr = sub.add_parser("mbr", help="Run PEAR MBR over JSONL n-best lists.")
45
+ _add_model_args(mbr)
46
+ mbr.add_argument(
47
+ "--input",
48
+ type=Path,
49
+ required=True,
50
+ help="JSONL with src and hypotheses fields.",
51
+ )
52
+ mbr.add_argument("--output", type=Path)
53
+ mbr.add_argument("--utility", choices=["full", "half"], default="half")
54
+ mbr.add_argument(
55
+ "--batch-size",
56
+ type=int,
57
+ default=8,
58
+ help="Number of pairwise utility comparisons per PEAR forward batch.",
59
+ )
60
+ mbr.add_argument(
61
+ "--gpus",
62
+ type=_parse_gpus,
63
+ default="auto",
64
+ help=(
65
+ "GPU usage: auto uses one available CUDA/MPS device, 0 forces CPU, "
66
+ "and 1 forces one accelerator device."
67
+ ),
68
+ )
69
+
70
+ args = parser.parse_args()
71
+ if args.command == "score":
72
+ _score(args)
73
+ elif args.command == "mbr":
74
+ _mbr(args)
75
+
76
+
77
+ def _add_model_args(parser: argparse.ArgumentParser) -> None:
78
+ parser.add_argument(
79
+ "--model",
80
+ default="pear",
81
+ help=(
82
+ "Model to load: pear, pear-xl, a Hugging Face repo id, or a local "
83
+ ".ckpt path. Defaults to pear."
84
+ ),
85
+ )
86
+ parser.add_argument(
87
+ "--hf-model",
88
+ help=(
89
+ "Explicit Hugging Face repo id or alias to load. This is a clearer "
90
+ "alternative to passing the same value through --model."
91
+ ),
92
+ )
93
+ parser.add_argument(
94
+ "--checkpoint",
95
+ type=Path,
96
+ help=(
97
+ "Explicit local Lightning .ckpt path to load. This is a clearer "
98
+ "alternative to passing the same path through --model."
99
+ ),
100
+ )
101
+ parser.add_argument("--cache-dir", type=Path)
102
+ parser.add_argument("--local-files-only", action="store_true")
103
+ parser.add_argument(
104
+ "--revision",
105
+ help="Hugging Face checkpoint revision (branch, tag, or commit SHA).",
106
+ )
107
+ parser.add_argument(
108
+ "--encoder-revision",
109
+ help="Hugging Face base-encoder revision (branch, tag, or commit SHA).",
110
+ )
111
+
112
+
113
+ def _parse_gpus(value: str) -> int | str:
114
+ if value == "auto":
115
+ return value
116
+ try:
117
+ gpus = int(value)
118
+ except ValueError as exc:
119
+ raise argparse.ArgumentTypeError("expected auto, 0, or 1") from exc
120
+ if gpus not in {0, 1}:
121
+ raise argparse.ArgumentTypeError("expected auto, 0, or 1")
122
+ return gpus
123
+
124
+
125
+ def _resolve_model_arg(args: argparse.Namespace) -> str | Path:
126
+ selected = [args.checkpoint is not None, args.hf_model is not None]
127
+ if sum(selected) > 1:
128
+ raise SystemExit("Use only one of --checkpoint or --hf-model.")
129
+ if args.checkpoint is not None:
130
+ return args.checkpoint
131
+ if args.hf_model is not None:
132
+ return args.hf_model
133
+ return args.model
134
+
135
+
136
+ def _score(args: argparse.Namespace) -> None:
137
+ model = load_metric(
138
+ _resolve_model_arg(args),
139
+ cache_dir=args.cache_dir,
140
+ local_files_only=args.local_files_only,
141
+ revision=args.revision,
142
+ encoder_revision=args.encoder_revision,
143
+ )
144
+ rows = list(csv.DictReader(args.input.open(newline=""), delimiter="\t"))
145
+ sources = [row["src"] for row in rows]
146
+ mode = "both" if args.both else "single"
147
+ if args.mode == "pairwise":
148
+ scores = score_pairwise(
149
+ model,
150
+ sources,
151
+ [r["mt_0"] for r in rows],
152
+ [r["mt_1"] for r in rows],
153
+ mode=mode,
154
+ batch_size=args.batch_size,
155
+ gpus=args.gpus,
156
+ )
157
+ else:
158
+ scores = score_reference_anchored(
159
+ model,
160
+ sources,
161
+ [r["mt"] for r in rows],
162
+ [r["ref"] for r in rows],
163
+ mode=mode,
164
+ batch_size=args.batch_size,
165
+ gpus=args.gpus,
166
+ )
167
+ if isinstance(scores, dict):
168
+ for key, values in scores.items():
169
+ for row, value in zip(rows, values):
170
+ row[f"pear_{key}"] = value
171
+ else:
172
+ for row, value in zip(rows, scores):
173
+ row["pear_score"] = value
174
+ fieldnames = list(rows[0]) if rows else []
175
+ output = args.output or args.input.with_suffix(".pear.tsv")
176
+ with output.open("w", newline="") as out:
177
+ writer = csv.DictWriter(out, fieldnames=fieldnames, delimiter="\t")
178
+ writer.writeheader()
179
+ writer.writerows(rows)
180
+
181
+
182
+ def _mbr(args: argparse.Namespace) -> None:
183
+ model = load_metric(
184
+ _resolve_model_arg(args),
185
+ cache_dir=args.cache_dir,
186
+ local_files_only=args.local_files_only,
187
+ revision=args.revision,
188
+ encoder_revision=args.encoder_revision,
189
+ )
190
+ output = args.output or args.input.with_suffix(".pear-mbr.jsonl")
191
+ with args.input.open() as src, output.open("w") as out:
192
+ for line in src:
193
+ item = json.loads(line)
194
+ matrix = pear_utility_matrix(
195
+ model,
196
+ item["src"],
197
+ item["hypotheses"],
198
+ mode=args.utility,
199
+ batch_size=args.batch_size,
200
+ gpus=args.gpus,
201
+ )
202
+ index, utility = select_mbr_hypothesis(matrix)
203
+ item.update(
204
+ {
205
+ "selected_index": index,
206
+ "selected": item["hypotheses"][index],
207
+ "utility": utility,
208
+ }
209
+ )
210
+ out.write(json.dumps(item, ensure_ascii=False) + "\n")
211
+
212
+
213
+ if __name__ == "__main__":
214
+ main()
@@ -0,0 +1,8 @@
1
+ from .xlmr import XLMREncoder
2
+ from .xlmr_xl import XLMRXLEncoder
3
+
4
+ str2encoder = {
5
+ "XLM-RoBERTa": XLMREncoder,
6
+ "XLM-RoBERTa-XL": XLMRXLEncoder,
7
+ "InfoXLM": XLMREncoder,
8
+ }
pear/encoders/base.py ADDED
@@ -0,0 +1,393 @@
1
+ r"""
2
+ Encoder Model base
3
+ ====================
4
+ Module defining the common interface between all pretrained encoder models.
5
+ """
6
+
7
+ import abc
8
+ from typing import Dict, List
9
+
10
+ import torch
11
+ import torch.nn as nn
12
+ from transformers import PreTrainedTokenizerBase
13
+
14
+
15
+ class Encoder(nn.Module, metaclass=abc.ABCMeta):
16
+ """Base class for an encoder model."""
17
+
18
+ tokenizer: PreTrainedTokenizerBase
19
+ model: nn.Module
20
+
21
+ @property
22
+ @abc.abstractmethod
23
+ def output_units(self) -> int:
24
+ """
25
+ Hidden size of the encoder model.
26
+
27
+ Returns:
28
+ int: Hidden size of the encoder model.
29
+ """
30
+ pass
31
+
32
+ @property
33
+ @abc.abstractmethod
34
+ def max_positions(self) -> int:
35
+ """
36
+ Max number of tokens the encoder handles.
37
+
38
+ Returns:
39
+ int: Max number of tokens the encoder handles.
40
+ """
41
+ pass
42
+
43
+ @property
44
+ @abc.abstractmethod
45
+ def num_layers(self) -> int:
46
+ """
47
+ Number of model layers available.
48
+
49
+ Returns:
50
+ int: Number of model layers available.
51
+ """
52
+ pass
53
+
54
+ @property
55
+ @abc.abstractmethod
56
+ def size_separator(self) -> int:
57
+ """Size of the seperator.
58
+ E.g: For BERT is just 1 (`[SEP]`) but models such as XLM-R and InfoXLM use 2 (`</s></s>`).
59
+
60
+ Returns:
61
+ int: Number of tokens used between two segments.
62
+ """
63
+ pass
64
+
65
+ @classmethod
66
+ @abc.abstractmethod
67
+ def from_pretrained(
68
+ cls,
69
+ pretrained_model: str,
70
+ load_pretrained_weights: bool = True,
71
+ local_files_only: bool = False,
72
+ revision: str | None = None,
73
+ ) -> "Encoder":
74
+ """
75
+ Function that loads a pretrained encoder and the respective tokenizer.
76
+
77
+ Args:
78
+ pretrained_model (str): Name of the pretrained model.
79
+ load_pretrained_weights (bool): Whether to load pretrained weights.
80
+ local_files_only (bool): Whether to only use locally cached files.
81
+ revision (str | None): Hugging Face revision to load.
82
+
83
+ Returns:
84
+ Encoder: Pretrained model from Hugging Face.
85
+
86
+ Raises:
87
+ NotImplementedError: If the method is not implemented.
88
+ """
89
+ raise NotImplementedError
90
+
91
+ def freeze(self) -> None:
92
+ """
93
+ Freezes the entire encoder.
94
+ """
95
+ for param in self.parameters():
96
+ param.requires_grad = False
97
+
98
+ def unfreeze(self) -> None:
99
+ """
100
+ Unfreezes the entire encoder.
101
+ """
102
+ for param in self.parameters():
103
+ param.requires_grad = True
104
+
105
+ @abc.abstractmethod
106
+ def freeze_embeddings(self) -> None:
107
+ """
108
+ Freezes the embedding layer.
109
+ """
110
+ pass
111
+
112
+ @abc.abstractmethod
113
+ def layerwise_lr(self, lr: float, decay: float) -> List:
114
+ """
115
+ Calculates the learning rate for each encoder layer by applying a small decay.
116
+
117
+ Args:
118
+ lr (float): Learning rate for the highest encoder layer.
119
+ decay (float): decay percentage for the lower layers.
120
+
121
+ Returns:
122
+ List: List of model parameters for all layers and the corresponding learning rate.
123
+ """
124
+ pass
125
+
126
+ @abc.abstractmethod
127
+ def forward(
128
+ self, input_ids: torch.Tensor, attention_mask: torch.Tensor, **kwargs
129
+ ) -> torch.Tensor:
130
+ """
131
+ Encoder model forward.
132
+
133
+ Args:
134
+ input_ids (torch.Tensor): Tokenized batch.
135
+ attention_mask (torch.Tensor): Batch attention mask.
136
+
137
+ Returns:
138
+ torch.Tensor: All layers embeddings.
139
+ """
140
+ pass
141
+
142
+ def prepare_sample(
143
+ self,
144
+ sample: List[str],
145
+ ) -> Dict[str, torch.Tensor]:
146
+ """
147
+ Receives a list of strings and applies tokenization and vectorization.
148
+
149
+ Args:
150
+ sample (List[str]): List with text segments to be tokenized and padded.
151
+
152
+ Returns:
153
+ Dict[str, torch.Tensor]: dict with `'input_ids'` and `'attention_mask'`.
154
+ """
155
+ return {
156
+ k: v
157
+ for k, v in self.tokenizer(
158
+ sample,
159
+ return_tensors="pt",
160
+ padding=True,
161
+ truncation=True,
162
+ max_length=self.max_positions,
163
+ ).items()
164
+ }
165
+
166
+ def pad_tensor(
167
+ self, tensor: torch.Tensor, length: int, padding_index: int
168
+ ) -> torch.Tensor:
169
+ """
170
+ Pad a tensor to length with padding_index.
171
+
172
+ Args:
173
+ tensor (torch.Tensor): Tensor to pad.
174
+ length (int): Sequence length after padding.
175
+ padding_index (int): Index to pad tensor with.
176
+
177
+ Returns:
178
+ torch.Tensor: Input batch
179
+ """
180
+ n_padding = length - tensor.shape[0]
181
+ assert n_padding >= 0
182
+ if n_padding == 0:
183
+ return tensor
184
+ padding = tensor.new(n_padding, *tensor.shape[1:]).fill_(padding_index)
185
+ return torch.cat((tensor, padding), dim=0)
186
+
187
+ def concat_sequences(
188
+ self,
189
+ inputs: List[Dict[str, torch.Tensor]],
190
+ return_span_masks: bool = False,
191
+ ) -> Dict[str, torch.Tensor]:
192
+ """
193
+ Concatenate K tokenized sequences per example into a single encoder input, distributing the length budget across segments before concatenation.
194
+ Optionally it returns span masks for SRC/MT0/MT1 content tokens (separators and pads are 0).
195
+ Works for:
196
+ - K=2 : SRC + TR (Absolute QE)
197
+ - K=3 : SRC + TR1 + TR2 (Pairwise)
198
+
199
+ Args:
200
+ inputs (List[Dict[str, torch.Tensor]]): List of model inputs.
201
+ return_span_masks (bool): Whether to return span masks. Default: `False`.
202
+
203
+ Returns:
204
+ Dict[str, torch.Tensor]: Returns a single model input with all sentences concatenated into a single input.
205
+ """
206
+ if len(inputs) == 0:
207
+ raise ValueError("concat_sequences expects at least one input dict.")
208
+
209
+ # 1) Strip padding via attention masks → python lists of ids per segment/example
210
+ segments: List[List[List[int]]] = []
211
+ for encoder_input in inputs:
212
+ ids = encoder_input["input_ids"] # [B, T]
213
+ am = encoder_input["attention_mask"].bool()
214
+ seg_i = [ids[b][am[b]].tolist() for b in range(ids.size(0))]
215
+ segments.append(seg_i)
216
+
217
+ K = len(segments)
218
+ B = len(segments[0])
219
+
220
+ if K != 2 and K != 3:
221
+ raise ValueError(
222
+ f"concat_sequences expects K=2 or K=3 segments, got K={K}."
223
+ )
224
+
225
+ # 2) Specials budget (depends on family via size_separator)
226
+ # BERT: [CLS] s1 [SEP] s2 [SEP] s3 [SEP] → specials = 1 + K
227
+ # RoBERTa/XLM-R/InfoXLM:
228
+ # K>=2: <s> s1 </s></s> s2 </s></s> s3 </s> ... → K + 2 (two double boundaries)
229
+ if self.size_separator == 1:
230
+ specials = 1 + K
231
+ else:
232
+ specials = 2 if K == 1 else 2 + (K - 1) * self.size_separator
233
+
234
+ total_content_budget = max(self.max_positions - specials, 0)
235
+
236
+ # Ids
237
+ sep_id = self.tokenizer.sep_token_id
238
+ cls_id = self.tokenizer.cls_token_id
239
+ if cls_id is None or sep_id is None:
240
+ raise ValueError("Tokenizer must define cls_token_id and sep_token_id.")
241
+ assert isinstance(cls_id, int) and isinstance(sep_id, int), (
242
+ f"cls_token_id and sep_token_id must be integers, got "
243
+ f"{type(cls_id)}: {cls_id!r}, {type(sep_id)}: {sep_id!r}"
244
+ )
245
+
246
+ batch_tokens: List[torch.Tensor] = []
247
+ # When masks requested, we store per-example seg-ids (0=special/pad, 1=SRC, 2=MT0, 3=MT1)
248
+ segid_lists: List[List[int]] = [] if return_span_masks else None # type: ignore
249
+
250
+ for b in range(B):
251
+ # content tokens per segment (strip outer specials)
252
+ contents = [segments[k][b][1:-1] for k in range(K)]
253
+ raw_lens = torch.tensor([len(c) for c in contents], dtype=torch.long)
254
+
255
+ # 3) Equal-split allocation with round-robin handout
256
+ q = total_content_budget // K
257
+ alloc = torch.full((K,), q, dtype=torch.long)
258
+ alloc = torch.minimum(alloc, raw_lens) # respect per-segment caps
259
+ leftover = int(total_content_budget - int(alloc.sum().item()))
260
+ while leftover > 0:
261
+ elig = torch.nonzero(raw_lens > alloc, as_tuple=False).flatten()
262
+ if elig.numel() == 0:
263
+ break
264
+ for t in elig.tolist():
265
+ i = int(t)
266
+ alloc[i] += 1
267
+ leftover -= 1
268
+ if leftover == 0:
269
+ break
270
+
271
+ # truncate contents to allocated budgets
272
+ contents = [contents[k][: int(alloc[k].item())] for k in range(K)]
273
+
274
+ merged: List[int] = []
275
+ segids: List[int] = [] if return_span_masks else None # type: ignore
276
+
277
+ if self.size_separator == 1:
278
+ # BERT: [CLS] s1 [SEP] s2 [SEP] (s3 [SEP])*
279
+ merged.append(cls_id)
280
+ if segids is not None:
281
+ segids.append(0)
282
+ # s1 (SRC)
283
+ for t in contents[0]:
284
+ merged.append(t)
285
+ if segids is not None:
286
+ segids.append(1)
287
+ merged.append(sep_id)
288
+ if segids is not None:
289
+ segids.append(0)
290
+ # s2 (MT0)
291
+ for t in contents[1]:
292
+ merged.append(t)
293
+ if segids is not None:
294
+ segids.append(2)
295
+ merged.append(sep_id)
296
+ if segids is not None:
297
+ segids.append(0)
298
+ # s3 (MT1) if present
299
+ if K == 3:
300
+ for t in contents[2]:
301
+ merged.append(t)
302
+ if segids is not None:
303
+ segids.append(3)
304
+ merged.append(sep_id)
305
+ if segids is not None:
306
+ segids.append(0)
307
+ else:
308
+ # RoBERTa/XLM-R/InfoXLM
309
+ merged.append(cls_id)
310
+ if segids is not None:
311
+ segids.append(0)
312
+ # <s> s1 </s></s> s2 </s></s> [ + s3 </s>]
313
+ # s1 (SRC)
314
+ for t in contents[0]:
315
+ merged.append(t)
316
+ if segids is not None:
317
+ segids.append(1)
318
+ # first double boundary
319
+ merged.append(sep_id)
320
+ merged.append(sep_id)
321
+ if segids is not None:
322
+ segids.append(0)
323
+ segids.append(0)
324
+ # s2 (MT0)
325
+ for t in contents[1]:
326
+ merged.append(t)
327
+ if segids is not None:
328
+ segids.append(2)
329
+ # second double boundary
330
+ merged.append(sep_id)
331
+ merged.append(sep_id)
332
+ if segids is not None:
333
+ segids.append(0)
334
+ segids.append(0)
335
+ # s3.. (MT1 if present)
336
+ if K == 3:
337
+ for t in contents[2]:
338
+ merged.append(t)
339
+ if segids is not None:
340
+ segids.append(3)
341
+ merged.append(sep_id)
342
+ if segids is not None:
343
+ segids.append(0)
344
+
345
+ assert (
346
+ len(merged) <= self.max_positions
347
+ ), f"Merged length {len(merged)} exceeds max_positions {self.max_positions}."
348
+
349
+ batch_tokens.append(torch.tensor(merged, dtype=torch.long))
350
+ if return_span_masks:
351
+ segid_lists.append(segids) # type: ignore
352
+
353
+ # 4) Pad and build attention mask
354
+ lengths = [t.size(0) for t in batch_tokens]
355
+ max_len = max(lengths)
356
+ pad_raw = self.tokenizer.pad_token_id
357
+ if not isinstance(pad_raw, int):
358
+ raise ValueError(
359
+ f"Tokenizer must have integer pad_token_id; got {pad_raw!r}."
360
+ )
361
+ pad_id: int = pad_raw
362
+
363
+ padded = [self.pad_tensor(t, max_len, pad_id) for t in batch_tokens]
364
+ padded = torch.stack(padded, dim=0).contiguous() # [B, L]
365
+ lens_t = torch.tensor(lengths, dtype=torch.long)
366
+ arange = torch.arange(max_len, device=padded.device)
367
+ attention_mask = (arange[None, :] < lens_t[:, None]).long() # [B, L]
368
+
369
+ out = {"input_ids": padded, "attention_mask": attention_mask}
370
+
371
+ if return_span_masks:
372
+ # Build masks from segids (0=special/pad)
373
+ src_mask = torch.zeros((B, max_len), dtype=torch.long)
374
+ mt0_mask = torch.zeros((B, max_len), dtype=torch.long)
375
+ mt1_mask = torch.zeros((B, max_len), dtype=torch.long) if K == 3 else None
376
+ for b in range(B):
377
+ Lb = lengths[b]
378
+ seg = segid_lists[b]
379
+ # copy segids into [B,L], pad tail stays 0
380
+ for i in range(Lb):
381
+ sid = seg[i]
382
+ if sid == 1:
383
+ src_mask[b, i] = 1
384
+ elif sid == 2:
385
+ mt0_mask[b, i] = 1
386
+ elif sid == 3 and K == 3:
387
+ mt1_mask[b, i] = 1 # type: ignore
388
+ out["src_mask"] = src_mask
389
+ out["mt0_mask"] = mt0_mask
390
+ if K == 3:
391
+ out["mt1_mask"] = mt1_mask # type: ignore
392
+
393
+ return out