pyhighlights 0.1.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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,19 @@
1
+ from pyhighlights.components.data import (
2
+ HighlightCollator,
3
+ HighlightDataset,
4
+ HighlightExample,
5
+ HighlightTokenizer,
6
+ HuggingFaceTokenizer,
7
+ TokenizedExample,
8
+ VocabularyTokenizer,
9
+ )
10
+
11
+ __all__ = [
12
+ "HighlightCollator",
13
+ "HighlightDataset",
14
+ "HighlightExample",
15
+ "HighlightTokenizer",
16
+ "HuggingFaceTokenizer",
17
+ "TokenizedExample",
18
+ "VocabularyTokenizer",
19
+ ]
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from numbers import Integral
5
+ from typing import Iterable, Mapping, Protocol, Sequence
6
+
7
+ import torch as th
8
+ from torch.utils.data import Dataset
9
+
10
+ from pyhighlights.components.models import InputData
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class HighlightExample:
15
+ sample_id: int
16
+ tokens: Sequence[str]
17
+ label: int
18
+ highlights: Sequence[int] | None = None
19
+
20
+ def __post_init__(self):
21
+ tokens = tuple(self.tokens)
22
+ highlights = None if self.highlights is None else tuple(self.highlights)
23
+ if not isinstance(self.sample_id, Integral) or not isinstance(
24
+ self.label, Integral
25
+ ):
26
+ raise TypeError("sample_id and label must be integers")
27
+ if any(not isinstance(token, str) for token in tokens):
28
+ raise TypeError("tokens must contain strings")
29
+ if highlights is not None:
30
+ if len(highlights) != len(tokens):
31
+ raise ValueError("highlights must align with tokens")
32
+ if any(value not in (0, 1) for value in highlights):
33
+ raise ValueError("highlights must contain only 0 or 1")
34
+ object.__setattr__(self, "tokens", tokens)
35
+ object.__setattr__(self, "highlights", highlights)
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TokenizedExample:
40
+ input_ids: Sequence[int]
41
+ word_ids: Sequence[int | None]
42
+
43
+ def __post_init__(self):
44
+ input_ids = tuple(self.input_ids)
45
+ word_ids = tuple(self.word_ids)
46
+ if len(input_ids) != len(word_ids):
47
+ raise ValueError("input_ids and word_ids must have equal length")
48
+ if any(not isinstance(value, Integral) or value < 0 for value in input_ids):
49
+ raise ValueError("input_ids must contain non-negative integers")
50
+ if any(
51
+ value is not None and (not isinstance(value, Integral) or value < 0)
52
+ for value in word_ids
53
+ ):
54
+ raise ValueError("word_ids must contain non-negative integers or None")
55
+ object.__setattr__(self, "input_ids", input_ids)
56
+ object.__setattr__(self, "word_ids", word_ids)
57
+
58
+
59
+ class HighlightTokenizer(Protocol):
60
+ pad_token_id: int
61
+
62
+ def encode(
63
+ self, tokens: Sequence[str], max_length: int | None = None
64
+ ) -> TokenizedExample: ...
65
+
66
+
67
+ class VocabularyTokenizer:
68
+ """One-token-to-one-id encoder for GRU backbones."""
69
+
70
+ def __init__(
71
+ self,
72
+ vocabulary: Mapping[str, int],
73
+ unknown_token_id: int = 0,
74
+ pad_token_id: int = 0,
75
+ ):
76
+ if min([unknown_token_id, pad_token_id, *vocabulary.values()]) < 0:
77
+ raise ValueError("token ids must be non-negative")
78
+ self.vocabulary = dict(vocabulary)
79
+ self.unknown_token_id = unknown_token_id
80
+ self.pad_token_id = pad_token_id
81
+
82
+ def encode(
83
+ self, tokens: Sequence[str], max_length: int | None = None
84
+ ) -> TokenizedExample:
85
+ tokens = tokens if max_length is None else tokens[:max_length]
86
+ return TokenizedExample(
87
+ input_ids=[
88
+ self.vocabulary.get(token, self.unknown_token_id) for token in tokens
89
+ ],
90
+ word_ids=list(range(len(tokens))),
91
+ )
92
+
93
+
94
+ class HuggingFaceTokenizer:
95
+ """Fast-tokenizer adapter preserving source-token alignment."""
96
+
97
+ def __init__(self, pretrained_model_card: str, **tokenizer_kwargs):
98
+ try:
99
+ from transformers import AutoTokenizer
100
+ except ImportError as error:
101
+ raise ImportError(
102
+ "HuggingFaceTokenizer requires pyhighlights[transformers]"
103
+ ) from error
104
+
105
+ if tokenizer_kwargs.pop("use_fast", True) is not True:
106
+ raise ValueError("HuggingFaceTokenizer requires a fast tokenizer")
107
+ self.tokenizer = AutoTokenizer.from_pretrained(
108
+ pretrained_model_card, use_fast=True, **tokenizer_kwargs
109
+ )
110
+ if not getattr(self.tokenizer, "is_fast", False):
111
+ raise ValueError("HuggingFaceTokenizer requires a fast tokenizer")
112
+ if self.tokenizer.pad_token_id is None:
113
+ raise ValueError("tokenizer must define pad_token_id")
114
+ self.pad_token_id = self.tokenizer.pad_token_id
115
+
116
+ def encode(
117
+ self, tokens: Sequence[str], max_length: int | None = None
118
+ ) -> TokenizedExample:
119
+ kwargs = {
120
+ "is_split_into_words": True,
121
+ "add_special_tokens": False,
122
+ "return_attention_mask": False,
123
+ }
124
+ if max_length is not None:
125
+ kwargs.update(truncation=True, max_length=max_length)
126
+ encoded = self.tokenizer(list(tokens), **kwargs)
127
+ try:
128
+ word_ids = encoded.word_ids()
129
+ except (AttributeError, ValueError) as error:
130
+ raise ValueError(
131
+ "tokenizer must provide word_ids; use a fast tokenizer"
132
+ ) from error
133
+ return TokenizedExample(
134
+ input_ids=encoded["input_ids"],
135
+ word_ids=word_ids,
136
+ )
137
+
138
+
139
+ class HighlightDataset(Dataset):
140
+ def __init__(self, examples: Iterable[HighlightExample]):
141
+ self.examples = list(examples)
142
+
143
+ def __len__(self) -> int:
144
+ return len(self.examples)
145
+
146
+ def __getitem__(self, index: int) -> HighlightExample:
147
+ return self.examples[index]
148
+
149
+
150
+ class HighlightCollator:
151
+ """Tokenize, align highlights to subtokens, and dynamically pad a batch."""
152
+
153
+ def __init__(
154
+ self,
155
+ tokenizer: HighlightTokenizer,
156
+ max_length: int | None = None,
157
+ ):
158
+ if max_length is not None and max_length < 1:
159
+ raise ValueError("max_length must be positive")
160
+ self.tokenizer = tokenizer
161
+ self.max_length = max_length
162
+
163
+ def __call__(self, examples: Sequence[HighlightExample]) -> InputData:
164
+ if not examples:
165
+ raise ValueError("cannot collate an empty batch")
166
+ encoded = [
167
+ self.tokenizer.encode(example.tokens, self.max_length)
168
+ for example in examples
169
+ ]
170
+ if self.max_length is not None:
171
+ encoded = [
172
+ TokenizedExample(
173
+ item.input_ids[: self.max_length],
174
+ item.word_ids[: self.max_length],
175
+ )
176
+ for item in encoded
177
+ ]
178
+ width = max(max(len(item.input_ids) for item in encoded), 1)
179
+
180
+ features = []
181
+ masks = []
182
+ highlights = []
183
+ for example, item in zip(examples, encoded):
184
+ input_ids = list(item.input_ids[:width])
185
+ word_ids = list(item.word_ids[:width])
186
+ for word_id in word_ids:
187
+ if word_id is not None and not 0 <= word_id < len(example.tokens):
188
+ raise ValueError("word_id is outside source-token range")
189
+
190
+ padding = width - len(input_ids)
191
+ features.append(input_ids + [self.tokenizer.pad_token_id] * padding)
192
+ masks.append(
193
+ [word_id is not None for word_id in word_ids] + [False] * padding
194
+ )
195
+ highlights.append(
196
+ [
197
+ -1
198
+ if word_id is None or example.highlights is None
199
+ else example.highlights[word_id]
200
+ for word_id in word_ids
201
+ ]
202
+ + [-1] * padding
203
+ )
204
+
205
+ return InputData(
206
+ features=th.tensor(features, dtype=th.long),
207
+ mask=th.tensor(masks, dtype=th.float32),
208
+ sample_ids=th.tensor([example.sample_id for example in examples]),
209
+ y_true=th.tensor([example.label for example in examples]),
210
+ highlight_true=th.tensor(highlights, dtype=th.long),
211
+ )
@@ -0,0 +1,21 @@
1
+ """Compatibility import path for data utilities."""
2
+
3
+ from pyhighlights.components.data import (
4
+ HighlightCollator,
5
+ HighlightDataset,
6
+ HighlightExample,
7
+ HighlightTokenizer,
8
+ HuggingFaceTokenizer,
9
+ TokenizedExample,
10
+ VocabularyTokenizer,
11
+ )
12
+
13
+ __all__ = [
14
+ "HighlightCollator",
15
+ "HighlightDataset",
16
+ "HighlightExample",
17
+ "HighlightTokenizer",
18
+ "HuggingFaceTokenizer",
19
+ "TokenizedExample",
20
+ "VocabularyTokenizer",
21
+ ]
@@ -0,0 +1,3 @@
1
+ from pyhighlights.components.models.data import InputData, OutputData, SPPOutput
2
+
3
+ __all__ = ["InputData", "OutputData", "SPPOutput"]
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ from typing import Dict, List, Literal, Tuple
5
+
6
+ import lightning as L
7
+ import torch as th
8
+ from cinnamon.registry import RegistrationKey, Registry
9
+ from torchmetrics import Metric, MetricCollection
10
+
11
+ from pyhighlights.components.models.data import (
12
+ InputData,
13
+ ModelData,
14
+ OutputData,
15
+ SPPOutput,
16
+ )
17
+ from pyhighlights.utility.losses import Loss, build_losses
18
+ from pyhighlights.utility.metrics import build_torchmetrics
19
+
20
+ Split = Literal["train", "val", "test"]
21
+
22
+ # Keep data containers importable from this module for compatibility.
23
+ __all__ = ["InputData", "Model", "ModelData", "OutputData", "SPPOutput", "Split"]
24
+
25
+
26
+ class Model(L.LightningModule, abc.ABC):
27
+ def __init__(
28
+ self,
29
+ name: str,
30
+ losses: List[RegistrationKey[Loss]],
31
+ optimizer: RegistrationKey[th.optim.Optimizer],
32
+ train_metrics: Dict[str, RegistrationKey[Metric]] | None = None,
33
+ val_metrics: Dict[str, RegistrationKey[Metric]] | None = None,
34
+ test_metrics: Dict[str, RegistrationKey[Metric]] | None = None,
35
+ ):
36
+ super().__init__()
37
+
38
+ self.save_hyperparameters(ignore=self.ignore_hyperparameters())
39
+ self.name = name
40
+ self.optimizer = optimizer
41
+
42
+ self.train_metrics = self._build_metrics(train_metrics)
43
+ self.val_metrics = self._build_metrics(val_metrics)
44
+ self.test_metrics = self._build_metrics(test_metrics)
45
+ self.losses = th.nn.ModuleList(build_losses(keys=losses))
46
+
47
+ self.store_predictions = False
48
+ self.predictions = []
49
+ self.forward_mapping = {
50
+ "train": self.training_forward,
51
+ "val": self.validation_forward,
52
+ "test": self.test_forward,
53
+ }
54
+
55
+ @staticmethod
56
+ def _build_metrics(
57
+ keys: Dict[str, RegistrationKey[Metric]] | None,
58
+ ) -> MetricCollection | None:
59
+ return build_torchmetrics(keys) if keys is not None else None
60
+
61
+ def ignore_hyperparameters(self) -> List[str]:
62
+ return []
63
+
64
+ def enable_storing_predictions(self):
65
+ self.store_predictions = True
66
+
67
+ def disable_storing_predictions(self):
68
+ self.store_predictions = False
69
+
70
+ def flush_predictions(self):
71
+ self.predictions.clear()
72
+
73
+ def update_metrics(
74
+ self, split: Split, input_data: InputData, output_data: OutputData
75
+ ):
76
+ metrics: MetricCollection | None = getattr(self, f"{split}_metrics")
77
+ if metrics is not None:
78
+ metrics.update(output_data.class_logits, input_data.y_true)
79
+
80
+ def compute_metrics(self, split: Split):
81
+ metrics: MetricCollection | None = getattr(self, f"{split}_metrics")
82
+ if metrics is None:
83
+ return
84
+
85
+ for key, value in metrics.compute().items():
86
+ self.log(f"{split}_{key}", value, prog_bar=True)
87
+ metrics.reset()
88
+
89
+ def on_train_epoch_end(self) -> None:
90
+ self.compute_metrics(split="train")
91
+
92
+ def on_validation_epoch_end(self) -> None:
93
+ self.compute_metrics(split="val")
94
+
95
+ def on_test_epoch_end(self) -> None:
96
+ self.compute_metrics(split="test")
97
+
98
+ def configure_optimizers(self):
99
+ return Registry.from_key(self.optimizer, params=self.parameters())
100
+
101
+ def log_metrics(
102
+ self,
103
+ split: Split,
104
+ total_loss: th.Tensor,
105
+ losses: Dict[str, th.Tensor],
106
+ batch_size: int,
107
+ ):
108
+ self.log(
109
+ name=f"{split}_loss",
110
+ value=total_loss,
111
+ on_step=False,
112
+ on_epoch=True,
113
+ prog_bar=True,
114
+ batch_size=batch_size,
115
+ )
116
+ for loss_name, loss_value in losses.items():
117
+ self.log(
118
+ name=f"{split}_{loss_name}",
119
+ value=loss_value,
120
+ on_step=False,
121
+ on_epoch=True,
122
+ prog_bar=True,
123
+ batch_size=batch_size,
124
+ )
125
+
126
+ def training_forward(self, batch: InputData) -> OutputData:
127
+ return self.forward(data=batch)
128
+
129
+ def validation_forward(self, batch: InputData) -> OutputData:
130
+ return self.training_forward(batch=batch)
131
+
132
+ def test_forward(self, batch: InputData) -> OutputData:
133
+ return self.training_forward(batch=batch)
134
+
135
+ def _step(self, batch: InputData, batch_idx: int, split: Split) -> th.Tensor:
136
+ output_data = self.forward_mapping[split](batch)
137
+ total_loss, losses = self.compute_loss(
138
+ input_data=batch, output_data=output_data
139
+ )
140
+
141
+ self.log_metrics(
142
+ split=split,
143
+ total_loss=total_loss,
144
+ losses=losses,
145
+ batch_size=batch.y_true.shape[0],
146
+ )
147
+ self.update_metrics(split=split, input_data=batch, output_data=output_data)
148
+
149
+ if self.store_predictions:
150
+ self.predictions.append({**batch.as_numpy(), **output_data.as_numpy()})
151
+
152
+ return total_loss
153
+
154
+ def training_step(self, batch: InputData, batch_idx: int):
155
+ return self._step(batch=batch, batch_idx=batch_idx, split="train")
156
+
157
+ def validation_step(self, batch: InputData, batch_idx: int):
158
+ return self._step(batch=batch, batch_idx=batch_idx, split="val")
159
+
160
+ def test_step(self, batch: InputData, batch_idx: int):
161
+ return self._step(batch=batch, batch_idx=batch_idx, split="test")
162
+
163
+ def compute_loss(
164
+ self,
165
+ input_data: InputData,
166
+ output_data: OutputData,
167
+ ) -> Tuple[th.Tensor, Dict[str, th.Tensor]]:
168
+ total_loss = output_data.class_logits.new_zeros(())
169
+ losses = {}
170
+
171
+ for loss in self.losses:
172
+ if not loss.enabled:
173
+ continue
174
+ loss_value = loss(input_data=input_data, output_data=output_data)
175
+ total_loss = total_loss + loss_value * loss.coefficient
176
+ losses[loss.name] = loss_value
177
+
178
+ return total_loss, losses
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, fields
4
+ from typing import Generator, TypeVar
5
+
6
+ import torch as th
7
+
8
+ D = TypeVar("D", bound="ModelData")
9
+
10
+
11
+ @dataclass
12
+ class ModelData:
13
+ def as_numpy(self):
14
+ return {
15
+ field.name: value.detach().cpu().numpy()
16
+ for field in fields(self)
17
+ if isinstance((value := getattr(self, field.name)), th.Tensor)
18
+ }
19
+
20
+ def unbind(self: D, dim: int = 0) -> Generator[D, None, None]:
21
+ tensor_fields = {
22
+ field.name: th.unbind(value, dim=dim)
23
+ for field in fields(self)
24
+ if isinstance((value := getattr(self, field.name)), th.Tensor)
25
+ }
26
+ sizes = {len(values) for values in tensor_fields.values()}
27
+ if not sizes:
28
+ raise RuntimeError("No tensors found to unbind")
29
+ if len(sizes) != 1:
30
+ raise RuntimeError("Cannot unbind tensors of different sizes")
31
+
32
+ for index in range(sizes.pop()):
33
+ yield type(self)(
34
+ **{
35
+ field.name: tensor_fields[field.name][index]
36
+ if field.name in tensor_fields
37
+ else getattr(self, field.name)
38
+ for field in fields(self)
39
+ }
40
+ )
41
+
42
+
43
+ @dataclass
44
+ class InputData(ModelData):
45
+ features: th.Tensor
46
+ mask: th.Tensor
47
+ sample_ids: th.Tensor
48
+ y_true: th.Tensor
49
+ highlight_true: th.Tensor
50
+
51
+
52
+ @dataclass
53
+ class OutputData(ModelData):
54
+ class_logits: th.Tensor
55
+
56
+ @property
57
+ def y_pred(self) -> th.Tensor:
58
+ """Compatibility alias for pre-0.2 code."""
59
+ return self.class_logits
60
+
61
+
62
+ @dataclass
63
+ class SPPOutput(OutputData):
64
+ highlight_logits: th.Tensor
65
+ highlight_mask: th.Tensor
66
+
67
+ @property
68
+ def highlight_pred(self) -> th.Tensor:
69
+ """Compatibility alias for pre-0.2 code."""
70
+ return self.highlight_mask
@@ -0,0 +1,41 @@
1
+ from pyhighlights.components.models.spp.base import (
2
+ SPPAggregator,
3
+ SPPBackbone,
4
+ SPPFirstAggregator,
5
+ SPPPredictor,
6
+ SPPSelector,
7
+ )
8
+ from pyhighlights.components.models.spp.fr import FR
9
+ from pyhighlights.components.models.spp.grat import (
10
+ GRAT,
11
+ AttentionGuider,
12
+ GRATGuider,
13
+ GRATGuiderOutput,
14
+ )
15
+ from pyhighlights.components.models.spp.implementations import (
16
+ GRUBackbone,
17
+ MLPPredictor,
18
+ MLPSelector,
19
+ TransformerBackbone,
20
+ )
21
+ from pyhighlights.components.models.spp.mcd import MCD
22
+ from pyhighlights.components.models.spp.mgr import MGR
23
+
24
+ __all__ = [
25
+ "AttentionGuider",
26
+ "FR",
27
+ "GRAT",
28
+ "GRATGuider",
29
+ "GRATGuiderOutput",
30
+ "GRUBackbone",
31
+ "MCD",
32
+ "MGR",
33
+ "MLPPredictor",
34
+ "MLPSelector",
35
+ "SPPAggregator",
36
+ "SPPBackbone",
37
+ "SPPFirstAggregator",
38
+ "SPPPredictor",
39
+ "SPPSelector",
40
+ "TransformerBackbone",
41
+ ]