shawhed commited on
Commit
0cd7593
·
verified ·
1 Parent(s): a4bf0c5

Focused model card; explicit generation parameters

Browse files
README.md CHANGED
@@ -12,8 +12,7 @@ tags:
12
  - non-autoregressive
13
  - masked-language-model
14
  - text-style-transfer
15
- - sentiment-transfer
16
- - text-rewriting
17
  - modernbert
18
  - emnlp2026
19
  language:
@@ -22,49 +21,79 @@ language:
22
 
23
  # SenseShift-base
24
 
25
- > **Generation from an encoder, with continuous control.**
26
- > An accepted EMNLP 2026 paper. Not a classifier, not a decoder — a masked LM
27
- > that *writes* text at a sentiment you dial in.
28
-
29
- SenseShift rewrites a sentence — or writes a new one — at **any sentiment you ask
30
- for on a continuous −1.0 to +1.0 scale**, while keeping it consistent with the
31
- surrounding text.
32
-
33
- Two things make it unusual:
34
-
35
- - **Continuous, not categorical.** Most sentiment-controlled generation offers
36
- you `positive` / `negative` / `neutral`. SenseShift takes a *number* — 21
37
- points on a 0.1 grid. Sweeping one sentence of a story, everything else fixed:
38
-
39
- | target | achieved | rewritten sentence |
40
- | ---: | ---: | --- |
41
- | −0.9 | −0.7 | The water was bitter and made him sick. |
42
- | −0.6 | −0.4 | The water was bitter and made him cough. |
43
- | +0.0 | +0.0 | The water was cold and made him wet. |
44
- | +0.6 | +0.7 | The water was clear and tasted very good. |
45
- | +0.9 | +0.8 | His friends laughed and played games with him. |
46
-
47
- - **The generator is an encoder.** Text generation is nearly always
48
- autoregressive decoding. SenseShift generates by iteratively filling masked
49
- positions in a bidirectional ModernBERT, so every token it writes is
50
- conditioned on the text on *both* sides. That is what lets it drop a
51
- replacement sentence into the middle of a passage and have it fit.
52
-
53
- It is a masked language model fine-tuned from ModernBERT-base with an explicit
54
- **control vocabulary**: 21 special tokens `[-1.0] … [1.0]` on a 0.1 grid. At
55
- training time every sentence is prefixed with its own VADER sentiment token and
56
- one sentence is masked out, so the model learns to write a replacement that
57
- realises the requested sentiment in context.
58
 
59
  | Model | Base | Params |
60
  | --- | --- | --- |
61
  | **SenseShift-base** (this model) | ModernBERT-base | 150M |
62
- | [SenseShift-large](https://huggingface.co/shawhed/SenseShift-large) | ModernBERT-large | 396M |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  ## Usage
65
 
66
- No install needed — the inference code ships inside this repo. Clone it and you
67
- have the weights and the code together:
68
 
69
  ```bash
70
  git clone https://huggingface.co/shawhed/SenseShift-base
@@ -78,138 +107,67 @@ from senseshift import SenseShift
78
 
79
  shifter = SenseShift.from_pretrained("SenseShift-base")
80
 
81
- text = ("The waiter greeted us at the door. "
82
- "The food arrived quickly and was still hot. "
83
- "We paid the bill and walked back to the hotel.")
84
 
85
- # Rewrite sentence 1 as strongly negative
86
- out = shifter.generate(text, generation_mode="rewrite", sentence_index=1, sentiment=-0.9)
87
- print(out.sentence) # -> "He looked sad and we felt bad."
88
- print(out.text) # the full passage with that sentence swapped in
89
 
90
- # Add a positive sentence after the last one
91
- out = shifter.generate(text, generation_mode="add", sentiment=0.7)
92
  print(out.text)
93
  ```
94
 
95
- Or let `huggingface_hub` fetch it into the local cache instead of cloning:
 
96
 
97
- ```python
98
- import sys
99
- from huggingface_hub import snapshot_download
100
-
101
- path = snapshot_download("shawhed/SenseShift-base")
102
- sys.path.insert(0, path)
103
 
104
- from senseshift import SenseShift
105
- shifter = SenseShift.from_pretrained(path)
106
- ```
 
 
 
 
 
107
 
108
- Requirements: `torch`, `transformers`, `huggingface-hub`, `nltk` (see
109
- `requirements.txt`). The VADER lexicon downloads itself on first use.
110
 
111
- ### `generate` arguments
 
 
 
 
 
 
 
 
112
 
113
- | Argument | Meaning |
114
- | --- | --- |
115
- | `text` | The passage to edit. |
116
- | `generation_mode` | `"rewrite"` replaces the sentence at `sentence_index`; `"add"` inserts a new sentence right after it. |
117
- | `sentiment` | `None` → keep the sentiment already there. `"random"` → a random grid value, excluding the current one. A number in `[-1, 1]` → that value, snapped to the nearest 0.1. |
118
- | `sentence_index` | Which sentence to act on. Defaults to a random sentence (`rewrite`) or the last sentence (`add`). Negative indices count from the end. |
119
- | `num_masks` | How many mask slots the model gets, i.e. roughly how long the new sentence is. Defaults to the replaced sentence's word count (`rewrite`) or `12` (`add`). |
120
- | `seed` | Seeds the random index / sentiment draws. |
121
 
122
- Decoding can be tuned per call: `top_k`, `beam_size`, `max_iters`, `alpha`
123
- (length normalisation), `gamma` (beam diversity penalty), `temperature`,
124
- `min_words`.
 
125
 
126
- `generate` returns a `SenseShiftOutput` with `.text`, `.sentence`,
127
- `.target_sentiment`, `.achieved_sentiment` (VADER of what was actually written),
128
- `.beams`, and friends. `str(out)` gives the edited passage.
129
 
130
- ## How it works
 
 
131
 
132
- 1. Split the passage into sentences and score each with VADER.
133
- 2. Prefix every sentence with its control token; give the target sentence the
134
- *requested* token and replace its words with `[MASK]`s.
135
- 3. Fill the masks left to right with beam search over the MLM head, with length
136
- normalisation and a diversity penalty, stopping at terminal punctuation.
137
- 4. Splice the decoded sentence back into the passage.
138
-
139
- Steps 1–4 live in the `senseshift` package, not in the weights — the checkpoint
140
- itself is a stock `ModernBertForMaskedLM` and can be loaded with
141
- `AutoModelForMaskedLM` if you want to build your own decoding loop.
142
-
143
- ## Control fidelity, and how far it transfers
144
-
145
- Measured over 50 held-out passages per domain, 9 targets each, rewriting one
146
- randomly chosen sentence. "Achieved" is VADER on the sentence the model wrote.
147
-
148
- | target | achieved — stories *(in-domain)* | achieved — reviews *(out-of-domain)* |
149
- | ---: | ---: | ---: |
150
- | −0.9 | −0.40 | −0.44 |
151
- | −0.6 | −0.23 | −0.25 |
152
- | −0.3 | −0.14 | −0.04 |
153
- | +0.0 | +0.09 | +0.01 |
154
- | +0.3 | +0.22 | +0.14 |
155
- | +0.6 | +0.53 | +0.47 |
156
- | +0.9 | +0.75 | +0.73 |
157
- | **MAE** | **0.33** | **0.33** |
158
-
159
- Two things to read off this.
160
-
161
- **The response is monotone and the positive half is well calibrated** — targets
162
- of +0.6 and +0.9 land within about 0.15. The negative half is compressed: ask
163
- for −0.9 and you reliably get *more negative*, but around −0.4 rather than −0.9.
164
-
165
- **Domain transfer is essentially free.** SenseShift was trained only on
166
- short children's stories, yet product reviews — a domain it never saw, with
167
- different vocabulary, register and length — come out at an identical MAE of
168
- 0.33. The per-target curve is the same shape. Use it outside the story domain
169
- with reasonable confidence.
170
-
171
- ## Training data and usable range
172
-
173
- Trained on TinyStories-style children's stories, labelled per sentence with
174
- VADER. That corpus is lopsided, which directly shapes what the model can do:
175
-
176
- | sentiment of training sentences | share |
177
- | --- | ---: |
178
- | negative (< 0) | 15.3% |
179
- | neutral (= 0) | 37.8% |
180
- | positive (> 0) | 47.0% |
181
- | exactly −1.0 | **0.00%** |
182
- | exactly +1.0 | 0.10% |
183
-
184
- So:
185
-
186
- - **Use targets in −0.8 … +0.9.** The `[-1.0]` control token appears *zero*
187
- times in training and `[1.0]` almost never; asking for ±1.0 gives an
188
- undertrained token and unpredictable output. `-1.0` in particular comes back
189
- near-neutral.
190
- - **Negative control is looser than positive**, because negative sentences are
191
- a third as common in the corpus. For strongly negative text, ask for −0.9 and
192
- expect roughly −0.4, or resample.
193
-
194
- The fidelity numbers above were measured on
195
- [SenseShift-large](https://huggingface.co/shawhed/SenseShift-large); this 150M
196
- variant follows the same shape but tracks the target less tightly.
197
-
198
- ## Limitations
199
-
200
- - English only; trained on short children's stories, though it transfers to
201
- other domains at no measured cost (see above).
202
- - VADER supplies the sentiment labels, so the model inherits its lexicon-based
203
- view of sentiment.
204
- - Control is a steer, not a guarantee: MAE 0.33 over the grid, with the negative
205
- half compressed and `±1.0` unusable. Single generations are noisy — check
206
- `out.achieved_sentiment` and resample if you need a specific value.
207
- - The rewrite is length-bounded by `num_masks`, so very long sentences are
208
- usually replaced by something shorter.
209
- - Because the whole passage is re-encoded per mask fill, generation cost grows
210
- with `num_masks × beam_size`.
211
 
212
  ## Citation
213
 
214
- Accepted to **EMNLP 2026**. A preprint and the full BibTeX entry will be linked
215
- here once they are public.
 
12
  - non-autoregressive
13
  - masked-language-model
14
  - text-style-transfer
15
+ - data-augmentation
 
16
  - modernbert
17
  - emnlp2026
18
  language:
 
21
 
22
  # SenseShift-base
23
 
24
+ Rewrite any sentence in a passage or write a new one — at a sentiment you
25
+ choose on a continuous scale from **−1.0 to +1.0**, in context.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  | Model | Base | Params |
28
  | --- | --- | --- |
29
  | **SenseShift-base** (this model) | ModernBERT-base | 150M |
30
+ | [SenseShift-large](https://huggingface.co/shawhed/SenseShift-base) | ModernBERT-large | 396M |
31
+
32
+ Accepted to EMNLP 2026.
33
+
34
+ ## How it works
35
+
36
+ Sentiment is a **control vocabulary**: 21 special tokens `[-1.0] … [1.0]` on a
37
+ 0.1 grid, added to the ModernBERT tokenizer.
38
+
39
+ Training: every sentence in a passage is scored with VADER and prefixed with its
40
+ own sentiment token. One sentence is then masked out. The model must reconstruct
41
+ it from the token it was given and the text on both sides — so it learns to
42
+ write a sentence that hits a requested sentiment *and* fits its neighbours.
43
+
44
+ Generation reverses this. Prefix each sentence with its current sentiment, give
45
+ the target sentence the sentiment you *want*, mask its words, and fill the masks
46
+ with beam search. Because the generator is a bidirectional encoder rather than a
47
+ decoder, every token it writes is conditioned on the text before **and** after —
48
+ which is what lets it replace a sentence mid-passage.
49
+
50
+ ## Examples
51
+
52
+ Rewriting sentence `[3]` at **−0.8**. The model picks up "tunnel" from the
53
+ preceding sentence:
54
+
55
+ > Once upon a time, there was a little boy named Timmy. One day, Timmy went to a
56
+ > park with his mommy. At the park, there was a big tunnel that Timmy wanted to
57
+ > explore. **~~Timmy's mommy said he could choose whether or not to go through
58
+ > the tunnel.~~ → But Timmy's mommy said no because it was too dangerous.**
59
+ > Timmy was brave and decided to go through the tunnel. As Timmy crawled through
60
+ > the tunnel, he felt mighty and strong. When he came out the other side, he was
61
+ > so happy and proud of himself. From that day on, Timmy loved going on
62
+ > adventures and choosing to be brave.
63
+
64
+ Rewriting sentence `[2]` of a restaurant review at **+0.8**. The model carries
65
+ the business name "Lotus 2" in from context:
66
+
67
+ > I ordered delivery this evening and I must say that I was pleasantly surprised
68
+ > at the level of customer service on the phone and at my door. I usually get my
69
+ > Chinese take out from best wok 2 and they pale in comparison to lotus 2.
70
+ > **~~I ordered the exact same things that I usually order from best wok and
71
+ > everything tasted better and was of better quality and was even a little less
72
+ > expensive.~~ → Lotus 2 are very friendly and always make sure that I get the
73
+ > best quality of food and I get a lot of value for my money.** I am so happy
74
+ > that I've found a new Chinese take out. And they deliver! Of course its not
75
+ > P.F. Changs. But its well worth what you pay.
76
+
77
+ ## Use cases
78
+
79
+ - **Writing assistant for fiction.** Adjust the emotional arc of a draft one
80
+ sentence at a time — darken a turning point, soften an ending — without
81
+ rewriting the surrounding prose.
82
+ - **Review and copy editing.** Retune the tone of a testimonial, product
83
+ description or release note while keeping the concrete details intact.
84
+ - **Data augmentation.** Generate sentiment-varied paraphrases of a corpus at
85
+ known target values: balance a skewed sentiment dataset, or produce minimal
86
+ pairs that differ in sentiment but share context.
87
+ - **Counterfactual and robustness testing.** Probe a downstream classifier with
88
+ inputs where exactly one sentence's sentiment moved, holding everything else
89
+ fixed.
90
+ - **Controllable-generation research.** A non-autoregressive baseline for
91
+ continuous attribute control, and a testbed for how far a control signal can
92
+ be pushed at inference time.
93
 
94
  ## Usage
95
 
96
+ No install needed — the inference code ships in this repo.
 
97
 
98
  ```bash
99
  git clone https://huggingface.co/shawhed/SenseShift-base
 
107
 
108
  shifter = SenseShift.from_pretrained("SenseShift-base")
109
 
110
+ text = ("At the park, there was a big tunnel that Timmy wanted to explore. "
111
+ "Timmy's mommy said he could choose whether or not to go through the tunnel. "
112
+ "Timmy was brave and decided to go through the tunnel.")
113
 
114
+ # Rewrite a sentence at a target sentiment
115
+ out = shifter.generate(text, generation_mode="rewrite", sentence_index=1, sentiment=-0.8)
116
+ print(out.sentence)
117
+ print(out.text)
118
 
119
+ # Write a new sentence and splice it in after sentence 2
120
+ out = shifter.generate(text, generation_mode="add", sentence_index=2, sentiment=0.9)
121
  print(out.text)
122
  ```
123
 
124
+ Requires `torch`, `transformers`, `huggingface-hub`, `nltk`. The VADER lexicon
125
+ downloads itself on first use.
126
 
127
+ ### `generate` arguments
 
 
 
 
 
128
 
129
+ | Argument | Default | Meaning |
130
+ | --- | --- | --- |
131
+ | `text` | — | The passage to edit. |
132
+ | `generation_mode` | `"rewrite"` | `"rewrite"` replaces the sentence at `sentence_index`; `"add"` inserts a new sentence after it. |
133
+ | `sentiment` | `None` | `None` keeps the current sentiment; `"random"` draws from the grid; a number in `[-1, 1]` is snapped to the nearest 0.1. |
134
+ | `sentence_index` | `None` | Which sentence to act on. Defaults to a random sentence (`rewrite`) or the last one (`add`). Negative indices count from the end. |
135
+ | `num_masks` | `None` | Mask slots given to the model, i.e. roughly how long the new sentence is. Defaults to the replaced sentence's word count, or 12 for `add`. |
136
+ | `seed` | `None` | Seeds the random index / sentiment draws. |
137
 
138
+ Decoding parameters leave any at `None` to use the model's defaults:
 
139
 
140
+ | Argument | Default | Meaning |
141
+ | --- | --- | --- |
142
+ | `beam_size` | `2` | Hypotheses kept alive. Higher is slower and usually more fluent. `num_beams` works as an alias. |
143
+ | `top_k` | `40` | Candidate tokens considered per masked position. |
144
+ | `temperature` | `0.8` | Below 1.0 is more conservative, above 1.0 more varied. |
145
+ | `alpha` | `0.7` | Length-normalisation exponent; higher tolerates longer output. |
146
+ | `gamma` | `0.05` | Diversity penalty on beams reusing the same token. |
147
+ | `max_iters` | `30` | Cap on mask-filling steps. |
148
+ | `min_words` | `3` | Tokens filled before the model may stop at punctuation. Raise to avoid very short rewrites. |
149
 
150
+ ```python
151
+ out = shifter.generate(text, sentence_index=1, sentiment=-0.8, beam_size=8, top_k=60, temperature=0.9)
152
+ ```
 
 
 
 
 
153
 
154
+ `generate` returns a `SenseShiftOutput` with `.text` (the edited passage),
155
+ `.sentence` (what was written), `.original_sentence`, `.target_sentiment`,
156
+ `.achieved_sentiment` (VADER of the result) and `.beams`. `str(out)` gives the
157
+ passage.
158
 
159
+ ## Training data
 
 
160
 
161
+ TinyStories-style short children's stories, labelled per sentence with VADER.
162
+ Despite the narrow training domain it transfers to other English prose — the
163
+ review example above is out-of-domain.
164
 
165
+ Negative sentiment is under-represented in that corpus, so the negative half of
166
+ the range is a looser steer than the positive half, and the extremes `±1.0` are
167
+ effectively untrained. **Use targets in −0.9 +0.9**, and read
168
+ `out.achieved_sentiment` if you need a specific value.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  ## Citation
171
 
172
+ Accepted to EMNLP 2026. A preprint and BibTeX entry will be linked here once
173
+ they are public.
senseshift/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (601 Bytes). View file
 
senseshift/__pycache__/decoding.cpython-311.pyc ADDED
Binary file (8.27 kB). View file
 
senseshift/__pycache__/masking.cpython-311.pyc ADDED
Binary file (5.33 kB). View file
 
senseshift/__pycache__/pipeline.cpython-311.pyc ADDED
Binary file (15.6 kB). View file
 
senseshift/__pycache__/text_utils.cpython-311.pyc ADDED
Binary file (7.1 kB). View file
 
senseshift/pipeline.py CHANGED
@@ -121,7 +121,14 @@ class SenseShift:
121
  sentence_index: Optional[int] = None,
122
  num_masks: Optional[int] = None,
123
  seed: Optional[int] = None,
124
- **overrides,
 
 
 
 
 
 
 
125
  ) -> SenseShiftOutput:
126
  """Rewrite one sentence, or add a new one, at a controlled sentiment.
127
 
@@ -140,8 +147,19 @@ class SenseShift:
140
  length of what it writes. Defaults to the replaced sentence's
141
  word count (``"rewrite"``) or ``add_num_masks`` (``"add"``).
142
  seed: Seed for the sentence/sentiment draws, for reproducibility.
143
- **overrides: Per-call decoding overrides ``top_k``, ``beam_size``,
144
- ``max_iters``, ``alpha``, ``gamma``, ``temperature``, ``min_words``.
 
 
 
 
 
 
 
 
 
 
 
145
 
146
  Returns:
147
  A :class:`SenseShiftOutput`; ``str(out)`` is the edited passage.
@@ -151,10 +169,23 @@ class SenseShift:
151
  f"generation_mode must be 'rewrite' or 'add', got {generation_mode!r}"
152
  )
153
 
154
- params = {**self.generation_defaults, **overrides}
155
- unknown = set(overrides) - set(DEFAULT_GENERATION)
156
- if unknown:
157
- raise TypeError(f"Unknown generation option(s): {', '.join(sorted(unknown))}")
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
159
  rng = _random.Random(seed) if seed is not None else _random
160
 
 
121
  sentence_index: Optional[int] = None,
122
  num_masks: Optional[int] = None,
123
  seed: Optional[int] = None,
124
+ beam_size: Optional[int] = None,
125
+ top_k: Optional[int] = None,
126
+ temperature: Optional[float] = None,
127
+ alpha: Optional[float] = None,
128
+ gamma: Optional[float] = None,
129
+ max_iters: Optional[int] = None,
130
+ min_words: Optional[int] = None,
131
+ num_beams: Optional[int] = None,
132
  ) -> SenseShiftOutput:
133
  """Rewrite one sentence, or add a new one, at a controlled sentiment.
134
 
 
147
  length of what it writes. Defaults to the replaced sentence's
148
  word count (``"rewrite"``) or ``add_num_masks`` (``"add"``).
149
  seed: Seed for the sentence/sentiment draws, for reproducibility.
150
+ beam_size: Hypotheses kept alive during decoding. Higher is slower
151
+ and usually more fluent. ``num_beams`` is accepted as an alias.
152
+ top_k: Candidate tokens considered at each masked position.
153
+ temperature: Softmax temperature. Below 1.0 is more conservative,
154
+ above 1.0 more varied.
155
+ alpha: Length-normalisation exponent; higher tolerates longer output.
156
+ gamma: Diversity penalty on beams reusing the same token.
157
+ max_iters: Cap on mask-filling steps.
158
+ min_words: Tokens that must be filled before the model may stop at
159
+ terminal punctuation. Raise it to prevent very short rewrites.
160
+
161
+ Any decoding argument left as ``None`` falls back to the model's
162
+ defaults from ``senseshift_config.json``.
163
 
164
  Returns:
165
  A :class:`SenseShiftOutput`; ``str(out)`` is the edited passage.
 
169
  f"generation_mode must be 'rewrite' or 'add', got {generation_mode!r}"
170
  )
171
 
172
+ if num_beams is not None:
173
+ if beam_size is not None and beam_size != num_beams:
174
+ raise TypeError("Pass either beam_size or num_beams, not both.")
175
+ beam_size = num_beams
176
+
177
+ overrides = {
178
+ "beam_size": beam_size, "top_k": top_k, "temperature": temperature,
179
+ "alpha": alpha, "gamma": gamma, "max_iters": max_iters,
180
+ "min_words": min_words,
181
+ }
182
+ params = {**self.generation_defaults,
183
+ **{k: v for k, v in overrides.items() if v is not None}}
184
+
185
+ if params["beam_size"] < 1 or params["top_k"] < 1:
186
+ raise ValueError("beam_size and top_k must be >= 1.")
187
+ if params["temperature"] <= 0:
188
+ raise ValueError("temperature must be > 0.")
189
 
190
  rng = _random.Random(seed) if seed is not None else _random
191