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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .data import Processor
2
+ from .model import SocialJev
3
+ from .types import Example, Query, Request, Result
4
+
5
+ __all__ = ["Example", "Processor", "Query", "Request", "Result", "SocialJev"]
@@ -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,3 @@
1
+ torch>=2.13
2
+ transformers<6,>=5.17
3
+ pydantic>=2
@@ -0,0 +1 @@
1
+ social_jev