social-jev 0.1.0a1__tar.gz
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.
- social_jev-0.1.0a1/LICENSE +21 -0
- social_jev-0.1.0a1/PKG-INFO +69 -0
- social_jev-0.1.0a1/README.md +56 -0
- social_jev-0.1.0a1/pyproject.toml +114 -0
- social_jev-0.1.0a1/setup.cfg +4 -0
- social_jev-0.1.0a1/src/social_jev/__init__.py +5 -0
- social_jev-0.1.0a1/src/social_jev/attention.py +137 -0
- social_jev-0.1.0a1/src/social_jev/config.py +60 -0
- social_jev-0.1.0a1/src/social_jev/data.py +362 -0
- social_jev-0.1.0a1/src/social_jev/model.py +145 -0
- social_jev-0.1.0a1/src/social_jev/py.typed +0 -0
- social_jev-0.1.0a1/src/social_jev/templates/messages.jinja +24 -0
- social_jev-0.1.0a1/src/social_jev/types.py +90 -0
- social_jev-0.1.0a1/src/social_jev.egg-info/PKG-INFO +69 -0
- social_jev-0.1.0a1/src/social_jev.egg-info/SOURCES.txt +16 -0
- social_jev-0.1.0a1/src/social_jev.egg-info/dependency_links.txt +1 -0
- social_jev-0.1.0a1/src/social_jev.egg-info/requires.txt +3 -0
- social_jev-0.1.0a1/src/social_jev.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Göttlich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: social-jev
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Jev-like classification foundation models for the social sciences
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: torch>=2.13
|
|
10
|
+
Requires-Dist: transformers<6,>=5.17
|
|
11
|
+
Requires-Dist: pydantic>=2
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# SocialJev
|
|
15
|
+
|
|
16
|
+
SocialJev is a Jev-like classification foundation model for the social sciences,
|
|
17
|
+
inspired by TypeSafe Jev. It is trained to combine the flexibility of modern
|
|
18
|
+
language models pretrained on trillions of tokens of text with the speed of
|
|
19
|
+
traditional lightweight NLP methods. Define your task through a simple typed
|
|
20
|
+
interface and SocialJev returns typed outputs.
|
|
21
|
+
|
|
22
|
+
**Note**
|
|
23
|
+
SocialJev is under active development. The API and model interfaces may change,
|
|
24
|
+
including breaking changes between releases.
|
|
25
|
+
|
|
26
|
+
## Getting started
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv pip install social-jev
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Load a model from Hugging Face and classify a batch of documents:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from social_jev import Query, Request, SocialJev
|
|
36
|
+
|
|
37
|
+
model = SocialJev.from_pretrained("social-jev/social-jev-0.6B-v1")
|
|
38
|
+
|
|
39
|
+
query = Query(
|
|
40
|
+
text="Which policy area does this document concern?",
|
|
41
|
+
options=["Transport", "Education", "Healthcare"],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
results = model.query(
|
|
45
|
+
[
|
|
46
|
+
Request(state="The council expanded the bus network.", queries=[query]),
|
|
47
|
+
Request(state="The government recruited more teachers.", queries=[query]),
|
|
48
|
+
],
|
|
49
|
+
batch_size=16,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
print(results[0][0].model_dump())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Example result:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
{
|
|
59
|
+
"choice": "Transport",
|
|
60
|
+
"probabilities": {
|
|
61
|
+
"Transport": 0.91,
|
|
62
|
+
"Education": 0.03,
|
|
63
|
+
"Healthcare": 0.06,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Results follow document order, with one result per query. The probabilities above
|
|
69
|
+
are illustrative.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SocialJev
|
|
2
|
+
|
|
3
|
+
SocialJev is a Jev-like classification foundation model for the social sciences,
|
|
4
|
+
inspired by TypeSafe Jev. It is trained to combine the flexibility of modern
|
|
5
|
+
language models pretrained on trillions of tokens of text with the speed of
|
|
6
|
+
traditional lightweight NLP methods. Define your task through a simple typed
|
|
7
|
+
interface and SocialJev returns typed outputs.
|
|
8
|
+
|
|
9
|
+
**Note**
|
|
10
|
+
SocialJev is under active development. The API and model interfaces may change,
|
|
11
|
+
including breaking changes between releases.
|
|
12
|
+
|
|
13
|
+
## Getting started
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
uv pip install social-jev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Load a model from Hugging Face and classify a batch of documents:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from social_jev import Query, Request, SocialJev
|
|
23
|
+
|
|
24
|
+
model = SocialJev.from_pretrained("social-jev/social-jev-0.6B-v1")
|
|
25
|
+
|
|
26
|
+
query = Query(
|
|
27
|
+
text="Which policy area does this document concern?",
|
|
28
|
+
options=["Transport", "Education", "Healthcare"],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
results = model.query(
|
|
32
|
+
[
|
|
33
|
+
Request(state="The council expanded the bus network.", queries=[query]),
|
|
34
|
+
Request(state="The government recruited more teachers.", queries=[query]),
|
|
35
|
+
],
|
|
36
|
+
batch_size=16,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
print(results[0][0].model_dump())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Example result:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
{
|
|
46
|
+
"choice": "Transport",
|
|
47
|
+
"probabilities": {
|
|
48
|
+
"Transport": 0.91,
|
|
49
|
+
"Education": 0.03,
|
|
50
|
+
"Healthcare": 0.06,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Results follow document order, with one result per query. The probabilities above
|
|
56
|
+
are illustrative.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "social-jev"
|
|
3
|
+
version = "0.1.0a1"
|
|
4
|
+
description = "Jev-like classification foundation models for the social sciences"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = ["torch>=2.13", "transformers>=5.17,<6", "pydantic>=2"]
|
|
10
|
+
|
|
11
|
+
[dependency-groups]
|
|
12
|
+
dev = [
|
|
13
|
+
"actionlint-py>=1.7.12.24",
|
|
14
|
+
"deptry>=0.25.1",
|
|
15
|
+
"mutmut>=3.7.0",
|
|
16
|
+
"prek>=0.5.2",
|
|
17
|
+
"pytest>=9.1.1",
|
|
18
|
+
"pytest-cov>=7.1.0",
|
|
19
|
+
"pytest-randomly>=5.0.0",
|
|
20
|
+
"ruff>=0.16.7",
|
|
21
|
+
"shellcheck-py>=0.11.0.1",
|
|
22
|
+
"ty>=0.0.80",
|
|
23
|
+
"vulture>=2.16",
|
|
24
|
+
"zizmor>=1.30.1",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["setuptools>=84.0.0"]
|
|
31
|
+
build-backend = "setuptools.build_meta"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-dir]
|
|
34
|
+
"" = "src"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
addopts = [
|
|
43
|
+
"--strict-config",
|
|
44
|
+
"--strict-markers",
|
|
45
|
+
"--cov=src",
|
|
46
|
+
"--cov=ci",
|
|
47
|
+
"--cov-branch",
|
|
48
|
+
"--cov-report=term-missing",
|
|
49
|
+
"--cov-fail-under=80",
|
|
50
|
+
]
|
|
51
|
+
xfail_strict = true
|
|
52
|
+
filterwarnings = ["error"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
[tool.ruff]
|
|
56
|
+
line-length = 100
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
extend-select = [
|
|
60
|
+
"A", # Prevent shadowing Python built-ins
|
|
61
|
+
"ARG", # Unused function arguments
|
|
62
|
+
"B", # Bug-prone Python patterns
|
|
63
|
+
"C4", # Better comprehensions
|
|
64
|
+
"C90", # McCabe complexity
|
|
65
|
+
"E", # pycodestyle errors
|
|
66
|
+
"ERA", # Commented-out code
|
|
67
|
+
"F", # Pyflakes, including stricter import checks
|
|
68
|
+
"N", # PEP 8 naming conventions
|
|
69
|
+
"PERF", # Performance anti-patterns
|
|
70
|
+
"PGH003", # Require specific type: ignore codes
|
|
71
|
+
"PGH004", # Require specific noqa codes
|
|
72
|
+
"PL", # Pylint-derived code quality checks
|
|
73
|
+
"PT", # Pytest-specific checks
|
|
74
|
+
"PTH", # Prefer pathlib
|
|
75
|
+
"RET", # Cleaner return control flow
|
|
76
|
+
"RUF", # Ruff-specific correctness/style checks
|
|
77
|
+
"SIM", # Simplify unnecessarily complex constructs
|
|
78
|
+
"TC", # Type-checking import hygiene
|
|
79
|
+
"TRY", # Exception-handling structure
|
|
80
|
+
"UP013", # Modern TypedDict syntax
|
|
81
|
+
"UP015", # Remove redundant open() modes
|
|
82
|
+
"UP042", # Prefer StrEnum
|
|
83
|
+
"W", # pycodestyle warnings
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# These conflict with, or are redundant with, `ruff format`.
|
|
87
|
+
ignore = [
|
|
88
|
+
"E111",
|
|
89
|
+
"E114",
|
|
90
|
+
"E117",
|
|
91
|
+
"W191",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[tool.ruff.lint.mccabe]
|
|
95
|
+
max-complexity = 6
|
|
96
|
+
|
|
97
|
+
[tool.ruff.lint.per-file-ignores]
|
|
98
|
+
"tests/**/*.py" = [
|
|
99
|
+
"PLR2004", # Literal values are normal and useful in assertions
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
[tool.check-tests]
|
|
103
|
+
fail = ["TST001", "TST002", "TST003", "TST004", "TST005"]
|
|
104
|
+
warn = []
|
|
105
|
+
|
|
106
|
+
[tool.mutmut]
|
|
107
|
+
source_paths = ["src", "ci"]
|
|
108
|
+
# mutation subsets must not fail whole-project coverage or vary their execution order
|
|
109
|
+
pytest_add_cli_args = ["--no-cov", "-p", "no:randomly"]
|
|
110
|
+
# protect explicit local incremental runs when dependencies change
|
|
111
|
+
on_dependency_change = "rerun"
|
|
112
|
+
|
|
113
|
+
[tool.setuptools.package-data]
|
|
114
|
+
social_jev = ["py.typed", "templates/*.jinja"]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Packed visibility masks and logical token positions."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from torch.nn.attention.flex_attention import create_block_mask
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .data import Batch
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def block_mask(ranges):
|
|
13
|
+
"""Build a FlexAttention block mask from allowed token ranges."""
|
|
14
|
+
batch, length = ranges.shape[:2]
|
|
15
|
+
|
|
16
|
+
def allowed(b, _h, q, k):
|
|
17
|
+
"""Return whether a query token may attend to a key token."""
|
|
18
|
+
valid = (q < length) & (k < length)
|
|
19
|
+
bounds = ranges[b, q.clamp(max=length - 1)]
|
|
20
|
+
return valid & (
|
|
21
|
+
((k >= bounds[0, 0]) & (k < bounds[0, 1]))
|
|
22
|
+
| ((k >= bounds[1, 0]) & (k < bounds[1, 1]))
|
|
23
|
+
| ((k >= bounds[2, 0]) & (k < bounds[2, 1]))
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return create_block_mask(allowed, batch, None, length, length, device=ranges.device)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def message_spans(boundaries, roles, answer_length):
|
|
30
|
+
"""Expose the appended answer prefix without changing stored message roles."""
|
|
31
|
+
start = 0
|
|
32
|
+
for index, (boundary, role) in enumerate(zip(boundaries, roles, strict=True)):
|
|
33
|
+
end = boundary + 1
|
|
34
|
+
final_option = role == "option" and (
|
|
35
|
+
index + 1 == len(roles) or roles[index + 1] != "option"
|
|
36
|
+
)
|
|
37
|
+
if answer_length and final_option:
|
|
38
|
+
yield start, end - answer_length, role
|
|
39
|
+
yield end - answer_length, end, "answer"
|
|
40
|
+
else:
|
|
41
|
+
yield start, end, role
|
|
42
|
+
start = end
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Positions:
|
|
46
|
+
"""Compute logical token positions for isolated queries and options.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
batch: Padded tokens and their message boundaries.
|
|
50
|
+
answer_length: Number of tokens in each appended answer prefix.
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
>>> positions = Positions(batch, answer_length=2) # doctest: +SKIP
|
|
54
|
+
>>> positions.value.shape == batch.input_ids.shape # doctest: +SKIP
|
|
55
|
+
True
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__( # noqa: C901 -- One branch per message role.
|
|
59
|
+
self, batch: "Batch", *, answer_length: int = 0
|
|
60
|
+
):
|
|
61
|
+
"""Build logical positions for all tokens in the batch."""
|
|
62
|
+
positions = []
|
|
63
|
+
for boundaries, roles in zip(batch.message_boundaries, batch.roles, strict=True):
|
|
64
|
+
sequence = []
|
|
65
|
+
state_length = query_length = longest_example = longest_option = 0
|
|
66
|
+
for start, end, role in message_spans(boundaries, roles, answer_length):
|
|
67
|
+
length = end - start
|
|
68
|
+
if role == "state":
|
|
69
|
+
state_length = length
|
|
70
|
+
query_length = longest_example = longest_option = 0
|
|
71
|
+
offset = 0
|
|
72
|
+
elif role == "query":
|
|
73
|
+
query_length = length
|
|
74
|
+
longest_example = longest_option = 0
|
|
75
|
+
offset = state_length
|
|
76
|
+
elif role == "example":
|
|
77
|
+
offset = state_length + query_length
|
|
78
|
+
longest_example = max(longest_example, length)
|
|
79
|
+
elif role == "answer":
|
|
80
|
+
offset = state_length + query_length + longest_example + longest_option
|
|
81
|
+
else: # option; Batch validates supported roles.
|
|
82
|
+
longest_option = max(longest_option, length)
|
|
83
|
+
offset = state_length + query_length + longest_example
|
|
84
|
+
sequence.extend(range(offset, offset + length))
|
|
85
|
+
padding = batch.input_ids.shape[1] - len(sequence)
|
|
86
|
+
positions.append(sequence + [0] * padding)
|
|
87
|
+
self.value = torch.tensor(positions, dtype=torch.long, device=batch.input_ids.device)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AttentionMask:
|
|
91
|
+
"""Compute causal visibility within documents, queries, and candidate answers.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
batch: Padded tokens and their message boundaries.
|
|
95
|
+
answer_length: Number of tokens in each appended answer prefix.
|
|
96
|
+
|
|
97
|
+
Examples:
|
|
98
|
+
>>> mask = AttentionMask(batch, answer_length=2) # doctest: +SKIP
|
|
99
|
+
>>> block_mask = mask.value # doctest: +SKIP
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__( # noqa: C901 -- One branch per message role.
|
|
103
|
+
self, batch: "Batch", *, answer_length: int = 0
|
|
104
|
+
):
|
|
105
|
+
"""Build the visibility mask for all tokens in the batch."""
|
|
106
|
+
batch_size, length = batch.input_ids.shape
|
|
107
|
+
tokens = torch.arange(length)
|
|
108
|
+
ranges = torch.zeros((batch_size, length, 3, 2), dtype=torch.int32)
|
|
109
|
+
ranges[:, :, 0, 0] = tokens
|
|
110
|
+
ranges[:, :, 0, 1] = tokens + 1
|
|
111
|
+
for index, (boundaries, roles) in enumerate(
|
|
112
|
+
zip(batch.message_boundaries, batch.roles, strict=True)
|
|
113
|
+
):
|
|
114
|
+
state = query = (0, 0)
|
|
115
|
+
context_end = 0
|
|
116
|
+
for start, end, role in message_spans(boundaries, roles, answer_length):
|
|
117
|
+
ranges[index, start:end, 0, 0] = start
|
|
118
|
+
ranges[index, start:end, 0, 1] = torch.arange(start + 1, end + 1)
|
|
119
|
+
if role == "state":
|
|
120
|
+
state = (start, end)
|
|
121
|
+
query = (0, 0)
|
|
122
|
+
context_end = 0
|
|
123
|
+
elif role == "query":
|
|
124
|
+
query = (start, end)
|
|
125
|
+
context_end = end
|
|
126
|
+
ranges[index, start:end, 1] = torch.tensor(state)
|
|
127
|
+
elif role == "example":
|
|
128
|
+
ranges[index, start:end, 1] = torch.tensor(state)
|
|
129
|
+
ranges[index, start:end, 2] = torch.tensor(query)
|
|
130
|
+
context_end = end
|
|
131
|
+
elif role == "answer":
|
|
132
|
+
ranges[index, start:end, 1] = torch.tensor(state)
|
|
133
|
+
ranges[index, start:end, 2] = torch.tensor([query[0], start])
|
|
134
|
+
else:
|
|
135
|
+
ranges[index, start:end, 1] = torch.tensor(state)
|
|
136
|
+
ranges[index, start:end, 2] = torch.tensor([query[0], context_end])
|
|
137
|
+
self.value = block_mask(ranges.to(batch.input_ids.device))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Hugging Face configuration for SocialJev LM-head inference."""
|
|
2
|
+
|
|
3
|
+
from string import ascii_uppercase
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from transformers import AutoConfig, PretrainedConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SocialJevConfig(PretrainedConfig):
|
|
10
|
+
"""Hugging Face configuration for an LM-head SocialJev checkpoint.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
backbone_config: Backbone configuration object or serialized configuration.
|
|
14
|
+
decision_head: Saved head configuration; only lm_head is supported.
|
|
15
|
+
option_codes: Single-token answer codes assigned to candidate options.
|
|
16
|
+
role_start: Special token starting a message.
|
|
17
|
+
role_end: Special token ending a message.
|
|
18
|
+
**kwargs: Additional Hugging Face configuration settings.
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
>>> config = SocialJevConfig(option_codes="ABC")
|
|
22
|
+
>>> config.decision_head["type"]
|
|
23
|
+
'lm_head'
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
model_type = "social_jev"
|
|
27
|
+
"""Hugging Face identifier for the SocialJev architecture."""
|
|
28
|
+
sub_configs: ClassVar = {"backbone_config": AutoConfig}
|
|
29
|
+
"""Configuration types used by the nested backbone."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
backbone_config=None,
|
|
34
|
+
decision_head=None,
|
|
35
|
+
option_codes=ascii_uppercase,
|
|
36
|
+
role_start="<|im_start|>",
|
|
37
|
+
role_end="<|im_end|>",
|
|
38
|
+
**kwargs,
|
|
39
|
+
):
|
|
40
|
+
"""Initialize the backbone, message delimiters, and answer codes."""
|
|
41
|
+
kwargs.pop("tie_word_embeddings", None)
|
|
42
|
+
super().__init__(**kwargs)
|
|
43
|
+
self.tie_word_embeddings = False
|
|
44
|
+
if backbone_config is None:
|
|
45
|
+
backbone_config = {"model_type": "qwen3"}
|
|
46
|
+
if isinstance(backbone_config, dict):
|
|
47
|
+
values = dict(backbone_config)
|
|
48
|
+
backbone_config = AutoConfig.for_model(values.pop("model_type"), **values)
|
|
49
|
+
self.backbone_config = backbone_config
|
|
50
|
+
self.backbone_config.tie_word_embeddings = False
|
|
51
|
+
if decision_head is not None and decision_head.get("type") != "lm_head":
|
|
52
|
+
message = "Only lm_head checkpoints are supported"
|
|
53
|
+
raise ValueError(message)
|
|
54
|
+
self.decision_head = {"type": "lm_head", "dims": [], "activation": "gelu"}
|
|
55
|
+
self.option_codes = option_codes
|
|
56
|
+
self.role_start = role_start
|
|
57
|
+
self.role_end = role_end
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
SocialJevConfig.register_for_auto_class()
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Pull-based inference preparation, exposed through Processor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from itertools import islice
|
|
9
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
|
|
13
|
+
from .types import Request
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from transformers import PreTrainedTokenizerBase
|
|
17
|
+
|
|
18
|
+
from .config import SocialJevConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Sample:
|
|
23
|
+
"""Formatted request messages before tokenization."""
|
|
24
|
+
|
|
25
|
+
messages: list[dict[str, Any]]
|
|
26
|
+
"""Role-tagged messages in request order."""
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_request(cls, request: Request) -> Sample:
|
|
30
|
+
"""Format a typed request into role-tagged messages."""
|
|
31
|
+
messages: list[dict[str, Any]] = [{"role": "state", "content": request.state}]
|
|
32
|
+
for query in request.queries:
|
|
33
|
+
messages.append({"role": "query", "content": query.text})
|
|
34
|
+
messages.extend(
|
|
35
|
+
{"role": "example", "content": example.model_dump()} for example in query.examples
|
|
36
|
+
)
|
|
37
|
+
messages.extend({"role": "option", "content": option} for option in query.options)
|
|
38
|
+
return cls(messages)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TokenizedSample:
|
|
43
|
+
"""Tokenized request with message boundaries and roles."""
|
|
44
|
+
|
|
45
|
+
ids: list[int]
|
|
46
|
+
"""Token IDs in message order."""
|
|
47
|
+
message_boundaries: list[int]
|
|
48
|
+
"""Inclusive final token index of each message."""
|
|
49
|
+
roles: list[str]
|
|
50
|
+
"""Message roles aligned with message boundaries."""
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_sample(cls, sample: Sample, processor: Processor) -> TokenizedSample:
|
|
54
|
+
"""Tokenize formatted messages and record their boundaries and roles."""
|
|
55
|
+
config = processor.config
|
|
56
|
+
ids = cast(
|
|
57
|
+
"list[int]",
|
|
58
|
+
processor.tokenizer.apply_chat_template(
|
|
59
|
+
sample.messages,
|
|
60
|
+
role_start=config.role_start,
|
|
61
|
+
role_end=config.role_end,
|
|
62
|
+
option_codes=config.option_codes,
|
|
63
|
+
tokenize=True,
|
|
64
|
+
return_dict=False,
|
|
65
|
+
add_generation_prompt=False,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
roles = [message["role"] for message in sample.messages]
|
|
69
|
+
closings = iter(i for i, token in enumerate(ids) if token == processor.end_id)
|
|
70
|
+
boundaries = []
|
|
71
|
+
for index, role in enumerate(roles):
|
|
72
|
+
if role == "example":
|
|
73
|
+
next(closings)
|
|
74
|
+
next(closings)
|
|
75
|
+
boundary = next(closings)
|
|
76
|
+
if role == "option" and (index + 1 == len(roles) or roles[index + 1] != "option"):
|
|
77
|
+
end = boundary + 1 + len(processor.answer_ids)
|
|
78
|
+
if ids[boundary + 1 : end] != processor.answer_ids:
|
|
79
|
+
message = "Tokenizer template must append the LM-head answer prefix"
|
|
80
|
+
raise ValueError(message)
|
|
81
|
+
boundary = end - 1
|
|
82
|
+
boundaries.append(boundary)
|
|
83
|
+
if len(ids) > config.backbone_config.max_position_embeddings:
|
|
84
|
+
message = "Request exceeds model context capacity"
|
|
85
|
+
raise ValueError(message)
|
|
86
|
+
return cls(ids, boundaries, roles)
|
|
87
|
+
|
|
88
|
+
def pad(self, length: int, pad_id: int) -> TokenizedSample:
|
|
89
|
+
"""Return a copy padded to the requested token length."""
|
|
90
|
+
return replace(self, ids=self.ids + [pad_id] * (length - len(self.ids)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Batch:
|
|
95
|
+
"""Padded CPU token tensors and aligned message metadata."""
|
|
96
|
+
|
|
97
|
+
input_ids: torch.Tensor
|
|
98
|
+
"""Padded token IDs with shape (documents, tokens)."""
|
|
99
|
+
message_boundaries: list[list[int]]
|
|
100
|
+
"""Inclusive message end indices for each document."""
|
|
101
|
+
roles: list[list[str]]
|
|
102
|
+
"""Message roles for each document, aligned with its boundaries."""
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_samples(cls, samples: list[TokenizedSample]) -> Batch:
|
|
106
|
+
"""Collate padded samples into a CPU batch."""
|
|
107
|
+
return cls(
|
|
108
|
+
torch.tensor([sample.ids for sample in samples], dtype=torch.long),
|
|
109
|
+
[sample.message_boundaries for sample in samples],
|
|
110
|
+
[sample.roles for sample in samples],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def to(self, device: torch.device | str) -> Batch:
|
|
114
|
+
"""Return a copy with token tensors transferred to the requested device."""
|
|
115
|
+
return replace(self, input_ids=self.input_ids.to(device))
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def option_positions(self) -> torch.Tensor:
|
|
119
|
+
"""Return the batch row and final token index of every option message."""
|
|
120
|
+
return torch.tensor(
|
|
121
|
+
[
|
|
122
|
+
(row, end)
|
|
123
|
+
for row, (boundaries, roles) in enumerate(
|
|
124
|
+
zip(self.message_boundaries, self.roles, strict=True)
|
|
125
|
+
)
|
|
126
|
+
for end, role in zip(boundaries, roles, strict=True)
|
|
127
|
+
if role == "option"
|
|
128
|
+
],
|
|
129
|
+
dtype=torch.long,
|
|
130
|
+
device=self.input_ids.device,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def option_indices(self) -> torch.Tensor:
|
|
135
|
+
"""Group option indices by query, using -1 for unavailable candidates."""
|
|
136
|
+
groups: list[list[int]] = []
|
|
137
|
+
index = 0
|
|
138
|
+
for roles in self.roles:
|
|
139
|
+
for role in roles:
|
|
140
|
+
if role == "query":
|
|
141
|
+
groups.append([])
|
|
142
|
+
elif role == "option":
|
|
143
|
+
groups[-1].append(index)
|
|
144
|
+
index += 1
|
|
145
|
+
width = max(map(len, groups))
|
|
146
|
+
return torch.tensor(
|
|
147
|
+
[group + [-1] * (width - len(group)) for group in groups],
|
|
148
|
+
dtype=torch.long,
|
|
149
|
+
device=self.input_ids.device,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Step[T](ABC):
|
|
154
|
+
"""Define the pull-based interface shared by inference pipeline stages.
|
|
155
|
+
|
|
156
|
+
Examples:
|
|
157
|
+
>>> source = RequestStep([])
|
|
158
|
+
>>> isinstance(source, Step)
|
|
159
|
+
True
|
|
160
|
+
>>> source.next() is None
|
|
161
|
+
True
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
@abstractmethod
|
|
165
|
+
def next(self) -> T | None:
|
|
166
|
+
"""Pull the next item, or return None when the upstream source is exhausted."""
|
|
167
|
+
...
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class RequestStep(Step[Request]):
|
|
171
|
+
"""Read typed requests in their original order.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
requests: Documents and queries to pass through the pipeline.
|
|
175
|
+
|
|
176
|
+
Examples:
|
|
177
|
+
>>> source = RequestStep([])
|
|
178
|
+
>>> source.next() is None
|
|
179
|
+
True
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(self, requests: list[Request]):
|
|
183
|
+
"""Initialize the ordered request source."""
|
|
184
|
+
self.requests = iter(requests)
|
|
185
|
+
|
|
186
|
+
def next(self) -> Request | None:
|
|
187
|
+
"""Pull the next item, or return None when the upstream source is exhausted."""
|
|
188
|
+
return next(self.requests, None)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class FormatStep(Step[Sample]):
|
|
192
|
+
"""Orchestrate conversion of requests into formatted samples.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
parent: Source of typed requests.
|
|
196
|
+
|
|
197
|
+
Examples:
|
|
198
|
+
>>> formatted = FormatStep(RequestStep([]))
|
|
199
|
+
>>> formatted.next() is None
|
|
200
|
+
True
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, parent: Step[Request]):
|
|
204
|
+
"""Attach the request source to the formatting stage."""
|
|
205
|
+
self.parent = parent
|
|
206
|
+
|
|
207
|
+
def next(self) -> Sample | None:
|
|
208
|
+
"""Pull the next item, or return None when the upstream source is exhausted."""
|
|
209
|
+
request = self.parent.next()
|
|
210
|
+
if request is None:
|
|
211
|
+
return None
|
|
212
|
+
return Sample.from_request(request)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TokenizeStep(Step[TokenizedSample]):
|
|
216
|
+
"""Orchestrate tokenization of formatted samples.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
parent: Source of formatted samples.
|
|
220
|
+
processor: Tokenizer and checkpoint settings used for tokenization.
|
|
221
|
+
|
|
222
|
+
Examples:
|
|
223
|
+
>>> formatted = FormatStep(RequestStep([]))
|
|
224
|
+
>>> tokenized = TokenizeStep(formatted, processor) # doctest: +SKIP
|
|
225
|
+
>>> tokenized.next() is None # doctest: +SKIP
|
|
226
|
+
True
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self, parent: Step[Sample], processor: Processor):
|
|
230
|
+
"""Attach the sample source and tokenizer settings."""
|
|
231
|
+
self.parent = parent
|
|
232
|
+
self.processor = processor
|
|
233
|
+
|
|
234
|
+
def next(self) -> TokenizedSample | None:
|
|
235
|
+
"""Pull the next item, or return None when the upstream source is exhausted."""
|
|
236
|
+
sample = self.parent.next()
|
|
237
|
+
if sample is None:
|
|
238
|
+
return None
|
|
239
|
+
return TokenizedSample.from_sample(sample, self.processor)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class PadStep(Step[list[TokenizedSample]]):
|
|
243
|
+
"""Collect requests and orchestrate padding to the group's longest sequence.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
parent: Source of tokenized samples.
|
|
247
|
+
batch_size: Maximum number of documents per group.
|
|
248
|
+
pad_id: Token ID used for padding.
|
|
249
|
+
|
|
250
|
+
Examples:
|
|
251
|
+
>>> padded = PadStep(tokenized, batch_size=16, pad_id=0) # doctest: +SKIP
|
|
252
|
+
>>> padded.next() is None # doctest: +SKIP
|
|
253
|
+
True
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(self, parent: Step[TokenizedSample], batch_size: int, pad_id: int):
|
|
257
|
+
"""Configure the upstream source, batch size, and padding token."""
|
|
258
|
+
if batch_size < 1:
|
|
259
|
+
message = "batch_size must be positive"
|
|
260
|
+
raise ValueError(message)
|
|
261
|
+
self.parent = parent
|
|
262
|
+
self.batch_size = batch_size
|
|
263
|
+
self.pad_id = pad_id
|
|
264
|
+
|
|
265
|
+
def next(self) -> list[TokenizedSample] | None:
|
|
266
|
+
"""Pull the next item, or return None when the upstream source is exhausted."""
|
|
267
|
+
samples = list(islice(iter(self.parent.next, None), self.batch_size))
|
|
268
|
+
if not samples:
|
|
269
|
+
return None
|
|
270
|
+
length = max(len(sample.ids) for sample in samples)
|
|
271
|
+
return [sample.pad(length, self.pad_id) for sample in samples]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class CollateStep(Step[Batch]):
|
|
275
|
+
"""Orchestrate construction of CPU batches from padded sample groups.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
parent: Source of padded sample groups.
|
|
279
|
+
|
|
280
|
+
Examples:
|
|
281
|
+
>>> batches = CollateStep(padded) # doctest: +SKIP
|
|
282
|
+
>>> batch = batches.next() # doctest: +SKIP
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
def __init__(self, parent: Step[list[TokenizedSample]]):
|
|
286
|
+
"""Attach the padded sample source to the collator."""
|
|
287
|
+
self.parent = parent
|
|
288
|
+
|
|
289
|
+
def next(self) -> Batch | None:
|
|
290
|
+
"""Pull the next item, or return None when the upstream source is exhausted."""
|
|
291
|
+
samples = self.parent.next()
|
|
292
|
+
if samples is None:
|
|
293
|
+
return None
|
|
294
|
+
return Batch.from_samples(samples)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Processor:
|
|
298
|
+
"""Expose the pull-based request preparation chain.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
tokenizer: Tokenizer containing the checkpoint's chat template.
|
|
302
|
+
config: Model configuration defining message roles and option codes.
|
|
303
|
+
|
|
304
|
+
Examples:
|
|
305
|
+
>>> from social_jev import Query, Request
|
|
306
|
+
>>> processor = Processor(tokenizer, config) # doctest: +SKIP
|
|
307
|
+
>>> request = Request(state="The bus network expanded.", queries=[
|
|
308
|
+
... Query(text="Policy area?", options=["Transport", "Education"])
|
|
309
|
+
... ])
|
|
310
|
+
>>> batch = processor.encode(request) # doctest: +SKIP
|
|
311
|
+
>>> batch.input_ids.shape[0] # doctest: +SKIP
|
|
312
|
+
1
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(self, tokenizer: PreTrainedTokenizerBase, config: SocialJevConfig):
|
|
316
|
+
"""Initialize the tokenizer settings and validate single-token answer codes."""
|
|
317
|
+
self.tokenizer = tokenizer
|
|
318
|
+
self.config = config
|
|
319
|
+
self.end_id = cast("int", tokenizer.convert_tokens_to_ids(config.role_end))
|
|
320
|
+
self.answer_ids = tokenizer.encode(config.role_start + "answer\n", add_special_tokens=False)
|
|
321
|
+
self.code_token_ids: list[int] = []
|
|
322
|
+
for code in config.option_codes:
|
|
323
|
+
encoded = tokenizer.encode(
|
|
324
|
+
config.role_start + "answer\n" + code, add_special_tokens=False
|
|
325
|
+
)
|
|
326
|
+
if encoded[:-1] != self.answer_ids or encoded[-1] == tokenizer.unk_token_id:
|
|
327
|
+
message = "Each option code must be one known answer token"
|
|
328
|
+
raise ValueError(message)
|
|
329
|
+
self.code_token_ids.append(encoded[-1])
|
|
330
|
+
if len(set(self.code_token_ids)) != len(self.code_token_ids):
|
|
331
|
+
message = "Option codes must have distinct token IDs"
|
|
332
|
+
raise ValueError(message)
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def template() -> str:
|
|
336
|
+
"""Read the bundled LM-head chat template from package resources."""
|
|
337
|
+
return files("social_jev").joinpath("templates/messages.jinja").read_text()
|
|
338
|
+
|
|
339
|
+
def batches(self, requests: list[Request], *, batch_size: int = 16) -> CollateStep:
|
|
340
|
+
"""Assemble a fresh pull-based chain that produces padded CPU batches."""
|
|
341
|
+
if any(
|
|
342
|
+
len(query.options) > len(self.code_token_ids)
|
|
343
|
+
for request in requests
|
|
344
|
+
for query in request.queries
|
|
345
|
+
):
|
|
346
|
+
message = "Query has more options than available option codes"
|
|
347
|
+
raise ValueError(message)
|
|
348
|
+
source = RequestStep(requests)
|
|
349
|
+
formatted = FormatStep(source)
|
|
350
|
+
tokenized = TokenizeStep(formatted, self)
|
|
351
|
+
padded = PadStep(tokenized, batch_size, self.end_id)
|
|
352
|
+
return CollateStep(padded)
|
|
353
|
+
|
|
354
|
+
def encode(self, requests: Request | list[Request]) -> Batch:
|
|
355
|
+
"""Encode all supplied requests into one padded CPU batch."""
|
|
356
|
+
items = [requests] if isinstance(requests, Request) else requests
|
|
357
|
+
if not items:
|
|
358
|
+
message = "encode requires at least one request"
|
|
359
|
+
raise ValueError(message)
|
|
360
|
+
batch = self.batches(items, batch_size=len(items)).next()
|
|
361
|
+
assert batch is not None
|
|
362
|
+
return batch
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Hugging Face LM-head loading and offline batched inference."""
|
|
2
|
+
|
|
3
|
+
from typing import overload
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from torch.nn.attention.flex_attention import create_mask
|
|
7
|
+
from transformers import (
|
|
8
|
+
AutoModelForCausalLM,
|
|
9
|
+
AutoTokenizer,
|
|
10
|
+
PreTrainedModel,
|
|
11
|
+
PreTrainedTokenizerBase,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from .attention import AttentionMask, Positions
|
|
15
|
+
from .config import SocialJevConfig
|
|
16
|
+
from .data import Batch, Processor
|
|
17
|
+
from .types import Request, Result
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SocialJev(PreTrainedModel):
|
|
21
|
+
"""Classify documents with a Hugging Face LM-head checkpoint.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
config: Backbone and LM-head inference configuration.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
>>> from social_jev import Query, Request
|
|
28
|
+
>>> model = SocialJev.from_pretrained("social-jev/social-jev-0.6B-v1") # doctest: +SKIP
|
|
29
|
+
>>> request = Request(state="The bus network expanded.", queries=[
|
|
30
|
+
... Query(text="Policy area?", options=["Transport", "Education"])
|
|
31
|
+
... ])
|
|
32
|
+
>>> results = model.query([request], batch_size=16) # doctest: +SKIP
|
|
33
|
+
>>> results[0][0].choice # doctest: +SKIP
|
|
34
|
+
'Transport'
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
config_class = SocialJevConfig
|
|
38
|
+
"""Hugging Face configuration class used to load this model."""
|
|
39
|
+
base_model_prefix = "backbone"
|
|
40
|
+
"""Checkpoint prefix for backbone parameters."""
|
|
41
|
+
_supports_sdpa = True
|
|
42
|
+
"""Declare support for PyTorch scaled dot-product attention."""
|
|
43
|
+
_supports_flex_attn = True
|
|
44
|
+
"""Declare support for PyTorch FlexAttention."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: SocialJevConfig):
|
|
47
|
+
"""Initialize the backbone and LM head from the checkpoint configuration."""
|
|
48
|
+
super().__init__(config)
|
|
49
|
+
causal = AutoModelForCausalLM.from_config(
|
|
50
|
+
config.backbone_config,
|
|
51
|
+
attn_implementation=config._attn_implementation or "sdpa",
|
|
52
|
+
)
|
|
53
|
+
self.backbone = causal.base_model
|
|
54
|
+
self.decision_head = causal.get_output_embeddings()
|
|
55
|
+
self.post_init()
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
|
|
59
|
+
"""Load model and tokenizer assets using the same Hugging Face loading options."""
|
|
60
|
+
loaded = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs)
|
|
61
|
+
model = loaded[0] if isinstance(loaded, tuple) else loaded
|
|
62
|
+
assert isinstance(model, cls)
|
|
63
|
+
tokenizer_options = {
|
|
64
|
+
key: kwargs[key]
|
|
65
|
+
for key in (
|
|
66
|
+
"revision",
|
|
67
|
+
"cache_dir",
|
|
68
|
+
"local_files_only",
|
|
69
|
+
"token",
|
|
70
|
+
"force_download",
|
|
71
|
+
"subfolder",
|
|
72
|
+
"trust_remote_code",
|
|
73
|
+
)
|
|
74
|
+
if key in kwargs
|
|
75
|
+
}
|
|
76
|
+
tokenizer = AutoTokenizer.from_pretrained(
|
|
77
|
+
pretrained_model_name_or_path,
|
|
78
|
+
config=model.config.backbone_config,
|
|
79
|
+
**tokenizer_options,
|
|
80
|
+
)
|
|
81
|
+
assert isinstance(tokenizer, PreTrainedTokenizerBase)
|
|
82
|
+
model.processor = Processor(tokenizer, model.config)
|
|
83
|
+
return loaded
|
|
84
|
+
|
|
85
|
+
def forward(self, batch: Batch) -> torch.Tensor:
|
|
86
|
+
"""Score candidate answer codes for every query in the batch."""
|
|
87
|
+
answer_length = len(self.processor.answer_ids)
|
|
88
|
+
mask = AttentionMask(batch, answer_length=answer_length).value
|
|
89
|
+
if self.backbone.config._attn_implementation != "flex_attention":
|
|
90
|
+
size, length = batch.input_ids.shape
|
|
91
|
+
mask = create_mask(
|
|
92
|
+
mask.mask_mod, size, 1, length, length, device=str(batch.input_ids.device)
|
|
93
|
+
)
|
|
94
|
+
hidden = self.backbone(
|
|
95
|
+
input_ids=batch.input_ids,
|
|
96
|
+
position_ids=Positions(batch, answer_length=answer_length).value,
|
|
97
|
+
attention_mask={"full_attention": mask},
|
|
98
|
+
use_cache=False,
|
|
99
|
+
).last_hidden_state
|
|
100
|
+
indices = batch.option_indices
|
|
101
|
+
positions = batch.option_positions[indices.max(dim=-1).values]
|
|
102
|
+
answers = hidden[positions[:, 0], positions[:, 1]]
|
|
103
|
+
codes = torch.tensor(self.processor.code_token_ids, device=hidden.device)
|
|
104
|
+
scores = self.decision_head(answers)[:, codes[: indices.shape[1]]]
|
|
105
|
+
return scores.masked_fill(indices < 0, -torch.inf)
|
|
106
|
+
|
|
107
|
+
@overload
|
|
108
|
+
def query(self, requests: Request, *, batch_size: int = 16) -> list[Result]:
|
|
109
|
+
"""Classify requests in batches and return results in document and query order."""
|
|
110
|
+
|
|
111
|
+
@overload
|
|
112
|
+
def query(self, requests: list[Request], *, batch_size: int = 16) -> list[list[Result]]:
|
|
113
|
+
"""Classify requests in batches and return results in document and query order."""
|
|
114
|
+
|
|
115
|
+
@torch.inference_mode()
|
|
116
|
+
def query(
|
|
117
|
+
self, requests: Request | list[Request], *, batch_size: int = 16
|
|
118
|
+
) -> list[Result] | list[list[Result]]:
|
|
119
|
+
"""Classify requests in batches and return results in document and query order."""
|
|
120
|
+
self.eval()
|
|
121
|
+
single = not isinstance(requests, list)
|
|
122
|
+
items = [requests] if single else requests
|
|
123
|
+
chain = self.processor.batches(items, batch_size=batch_size)
|
|
124
|
+
pending = iter(items)
|
|
125
|
+
results: list[list[Result]] = []
|
|
126
|
+
while (batch := chain.next()) is not None:
|
|
127
|
+
rows = iter(self(batch.to(self.device)))
|
|
128
|
+
for _ in batch.roles:
|
|
129
|
+
request = next(pending)
|
|
130
|
+
answers = []
|
|
131
|
+
for query in request.queries:
|
|
132
|
+
probabilities = next(rows)[: len(query.options)].float().softmax(0).cpu()
|
|
133
|
+
answers.append(
|
|
134
|
+
Result(
|
|
135
|
+
choice=query.options[int(probabilities.argmax())],
|
|
136
|
+
probabilities=dict(
|
|
137
|
+
zip(query.options, probabilities.tolist(), strict=True)
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
results.append(answers)
|
|
142
|
+
return results[0] if single else results
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
SocialJev.register_for_auto_class("AutoModel")
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{%- set option_codes = option_codes|default('ABCDEFGHIJKLMNOPQRSTUVWXYZ') %}
|
|
2
|
+
{%- set role_start = role_start|default('<|im_start|>') %}
|
|
3
|
+
{%- set role_end = role_end|default('<|im_end|>') %}
|
|
4
|
+
{%- set counter = namespace(option=0) %}
|
|
5
|
+
{%- for message in messages %}
|
|
6
|
+
{%- if message.role == 'query' %}{%- set counter.option = 0 %}{%- endif %}
|
|
7
|
+
{{- role_start + message.role + '\n' }}
|
|
8
|
+
{%- if message.role == 'example' %}
|
|
9
|
+
{{- role_start + 'state\n' + message.content.state + role_end }}
|
|
10
|
+
{{- role_start + 'option\n' + message.content.choice + role_end }}
|
|
11
|
+
{%- elif message.role == 'option' %}
|
|
12
|
+
{%- if counter.option >= option_codes|length %}
|
|
13
|
+
{{- raise_exception('More options than available letter codes') }}
|
|
14
|
+
{%- endif %}
|
|
15
|
+
{{- option_codes[counter.option] + ': ' + message.content }}
|
|
16
|
+
{%- set counter.option = counter.option + 1 %}
|
|
17
|
+
{%- else %}
|
|
18
|
+
{{- message.content }}
|
|
19
|
+
{%- endif %}
|
|
20
|
+
{{- role_end }}
|
|
21
|
+
{%- if message.role == 'option' and (loop.last or messages[loop.index].role != 'option') %}
|
|
22
|
+
{{- role_start + 'answer\n' }}
|
|
23
|
+
{%- endif %}
|
|
24
|
+
{%- endfor %}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Typed document coding requests and results."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, model_validator
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Example(BaseModel):
|
|
7
|
+
"""A labelled document illustrating a candidate answer.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
state: Example document text.
|
|
11
|
+
choice: Answer matching one of the query's options.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
>>> example = Example(state="The bus network expanded.", choice="Transport")
|
|
15
|
+
>>> example.choice
|
|
16
|
+
'Transport'
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
state: str
|
|
20
|
+
"""Text of the labelled example document."""
|
|
21
|
+
choice: str
|
|
22
|
+
"""Candidate answer assigned to the example."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Query(BaseModel):
|
|
26
|
+
"""A classification question with candidate answers and optional examples.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
text: Question describing the classification task.
|
|
30
|
+
options: Nonempty list of distinct candidate answers.
|
|
31
|
+
examples: Labelled documents whose choices appear in options.
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
>>> query = Query(text="Policy area?", options=["Transport", "Education"])
|
|
35
|
+
>>> query.options[0]
|
|
36
|
+
'Transport'
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
text: str
|
|
40
|
+
"""Question defining the classification task."""
|
|
41
|
+
options: list[str] = Field(min_length=1)
|
|
42
|
+
"""Distinct candidate answers in their supplied order."""
|
|
43
|
+
examples: list[Example] = Field(default_factory=list)
|
|
44
|
+
"""Optional labelled examples for this query."""
|
|
45
|
+
|
|
46
|
+
@model_validator(mode="after")
|
|
47
|
+
def validate_options(self):
|
|
48
|
+
"""Require distinct options and example choices drawn from those options."""
|
|
49
|
+
assert len(self.options) == len(set(self.options))
|
|
50
|
+
assert all(example.choice in self.options for example in self.examples)
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Request(BaseModel):
|
|
55
|
+
"""A document and the classification questions to ask about it.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
state: Document text to classify.
|
|
59
|
+
queries: Nonempty list of questions about the document.
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
>>> query = Query(text="Policy area?", options=["Transport", "Education"])
|
|
63
|
+
>>> request = Request(state="The bus network expanded.", queries=[query])
|
|
64
|
+
>>> len(request.queries)
|
|
65
|
+
1
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
state: str
|
|
69
|
+
"""Document text to classify."""
|
|
70
|
+
queries: list[Query] = Field(min_length=1)
|
|
71
|
+
"""Questions to answer about the document, in output order."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Result(BaseModel):
|
|
75
|
+
"""The selected answer and probabilities over a query's candidate answers.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
choice: Candidate answer with the highest probability.
|
|
79
|
+
probabilities: Candidate answers mapped to their normalized probabilities.
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
>>> result = Result(choice="Transport", probabilities={"Transport": 0.9, "Education": 0.1})
|
|
83
|
+
>>> result.choice
|
|
84
|
+
'Transport'
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
choice: str
|
|
88
|
+
"""Candidate answer with the highest probability."""
|
|
89
|
+
probabilities: dict[str, float]
|
|
90
|
+
"""Probabilities normalized over the supplied candidate answers."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: social-jev
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: Jev-like classification foundation models for the social sciences
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: torch>=2.13
|
|
10
|
+
Requires-Dist: transformers<6,>=5.17
|
|
11
|
+
Requires-Dist: pydantic>=2
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# SocialJev
|
|
15
|
+
|
|
16
|
+
SocialJev is a Jev-like classification foundation model for the social sciences,
|
|
17
|
+
inspired by TypeSafe Jev. It is trained to combine the flexibility of modern
|
|
18
|
+
language models pretrained on trillions of tokens of text with the speed of
|
|
19
|
+
traditional lightweight NLP methods. Define your task through a simple typed
|
|
20
|
+
interface and SocialJev returns typed outputs.
|
|
21
|
+
|
|
22
|
+
**Note**
|
|
23
|
+
SocialJev is under active development. The API and model interfaces may change,
|
|
24
|
+
including breaking changes between releases.
|
|
25
|
+
|
|
26
|
+
## Getting started
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv pip install social-jev
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Load a model from Hugging Face and classify a batch of documents:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from social_jev import Query, Request, SocialJev
|
|
36
|
+
|
|
37
|
+
model = SocialJev.from_pretrained("social-jev/social-jev-0.6B-v1")
|
|
38
|
+
|
|
39
|
+
query = Query(
|
|
40
|
+
text="Which policy area does this document concern?",
|
|
41
|
+
options=["Transport", "Education", "Healthcare"],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
results = model.query(
|
|
45
|
+
[
|
|
46
|
+
Request(state="The council expanded the bus network.", queries=[query]),
|
|
47
|
+
Request(state="The government recruited more teachers.", queries=[query]),
|
|
48
|
+
],
|
|
49
|
+
batch_size=16,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
print(results[0][0].model_dump())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Example result:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
{
|
|
59
|
+
"choice": "Transport",
|
|
60
|
+
"probabilities": {
|
|
61
|
+
"Transport": 0.91,
|
|
62
|
+
"Education": 0.03,
|
|
63
|
+
"Healthcare": 0.06,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Results follow document order, with one result per query. The probabilities above
|
|
69
|
+
are illustrative.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/social_jev/__init__.py
|
|
5
|
+
src/social_jev/attention.py
|
|
6
|
+
src/social_jev/config.py
|
|
7
|
+
src/social_jev/data.py
|
|
8
|
+
src/social_jev/model.py
|
|
9
|
+
src/social_jev/py.typed
|
|
10
|
+
src/social_jev/types.py
|
|
11
|
+
src/social_jev.egg-info/PKG-INFO
|
|
12
|
+
src/social_jev.egg-info/SOURCES.txt
|
|
13
|
+
src/social_jev.egg-info/dependency_links.txt
|
|
14
|
+
src/social_jev.egg-info/requires.txt
|
|
15
|
+
src/social_jev.egg-info/top_level.txt
|
|
16
|
+
src/social_jev/templates/messages.jinja
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
social_jev
|