shawhed commited on
Commit
9d135a2
·
verified ·
1 Parent(s): 7b89916

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: answerdotai/ModernBERT-large
4
+ library_name: transformers
5
+ pipeline_tag: fill-mask
6
+ tags:
7
+ - sentiment-control
8
+ - controllable-text-generation
9
+ - text-rewriting
10
+ - modernbert
11
+ language:
12
+ - en
13
+ ---
14
+
15
+ # SenseShift-large
16
+
17
+ SenseShift rewrites a sentence — or writes a new one — at **any sentiment you ask
18
+ for on a continuous −1.0 to +1.0 scale**, while keeping it consistent with the
19
+ surrounding text.
20
+
21
+ It is a masked language model fine-tuned from ModernBERT-large with an explicit
22
+ **control vocabulary**: 21 special tokens `[-1.0] … [1.0]` on a 0.1 grid. At
23
+ training time every sentence is prefixed with its own VADER sentiment token and
24
+ one sentence is masked out, so the model learns to write a replacement that
25
+ realises the requested sentiment in context.
26
+
27
+ | Model | Base | Params |
28
+ | --- | --- | --- |
29
+ | [SenseShift-base](https://huggingface.co/shawhed/SenseShift-base) | ModernBERT-base | 150M |
30
+ | **SenseShift-large** (this model) | ModernBERT-large | 396M |
31
+
32
+ ## Usage
33
+
34
+ No install needed — the inference code ships inside this repo. Clone it and you
35
+ have the weights and the code together:
36
+
37
+ ```bash
38
+ git clone https://huggingface.co/shawhed/SenseShift-large
39
+ ```
40
+
41
+ ```python
42
+ import sys
43
+ sys.path.insert(0, "SenseShift-large")
44
+
45
+ from senseshift import SenseShift
46
+
47
+ shifter = SenseShift.from_pretrained("SenseShift-large")
48
+
49
+ text = ("The waiter greeted us at the door. "
50
+ "The food arrived quickly and was still hot. "
51
+ "We paid the bill and walked back to the hotel.")
52
+
53
+ # Rewrite sentence 1 as strongly negative
54
+ out = shifter.generate(text, generation_mode="rewrite", sentence_index=1, sentiment=-0.9)
55
+ print(out.sentence) # -> "He looked sad and we felt bad."
56
+ print(out.text) # the full passage with that sentence swapped in
57
+
58
+ # Add a positive sentence after the last one
59
+ out = shifter.generate(text, generation_mode="add", sentiment=0.7)
60
+ print(out.text)
61
+ ```
62
+
63
+ Or let `huggingface_hub` fetch it into the local cache instead of cloning:
64
+
65
+ ```python
66
+ import sys
67
+ from huggingface_hub import snapshot_download
68
+
69
+ path = snapshot_download("shawhed/SenseShift-large")
70
+ sys.path.insert(0, path)
71
+
72
+ from senseshift import SenseShift
73
+ shifter = SenseShift.from_pretrained(path)
74
+ ```
75
+
76
+ Requirements: `torch`, `transformers`, `huggingface-hub`, `nltk` (see
77
+ `requirements.txt`). The VADER lexicon downloads itself on first use.
78
+
79
+ ### `generate` arguments
80
+
81
+ | Argument | Meaning |
82
+ | --- | --- |
83
+ | `text` | The passage to edit. |
84
+ | `generation_mode` | `"rewrite"` replaces the sentence at `sentence_index`; `"add"` inserts a new sentence right after it. |
85
+ | `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. |
86
+ | `sentence_index` | Which sentence to act on. Defaults to a random sentence (`rewrite`) or the last sentence (`add`). Negative indices count from the end. |
87
+ | `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`). |
88
+ | `seed` | Seeds the random index / sentiment draws. |
89
+
90
+ Decoding can be tuned per call: `top_k`, `beam_size`, `max_iters`, `alpha`
91
+ (length normalisation), `gamma` (beam diversity penalty), `temperature`,
92
+ `min_words`.
93
+
94
+ `generate` returns a `SenseShiftOutput` with `.text`, `.sentence`,
95
+ `.target_sentiment`, `.achieved_sentiment` (VADER of what was actually written),
96
+ `.beams`, and friends. `str(out)` gives the edited passage.
97
+
98
+ ## How it works
99
+
100
+ 1. Split the passage into sentences and score each with VADER.
101
+ 2. Prefix every sentence with its control token; give the target sentence the
102
+ *requested* token and replace its words with `[MASK]`s.
103
+ 3. Fill the masks left to right with beam search over the MLM head, with length
104
+ normalisation and a diversity penalty, stopping at terminal punctuation.
105
+ 4. Splice the decoded sentence back into the passage.
106
+
107
+ Steps 1–4 live in the `senseshift` package, not in the weights — the checkpoint
108
+ itself is a stock `ModernBertForMaskedLM` and can be loaded with
109
+ `AutoModelForMaskedLM` if you want to build your own decoding loop.
110
+
111
+ ## Limitations
112
+
113
+ - English only; trained on short narrative and review-style text.
114
+ - VADER supplies the sentiment labels, so the model inherits its lexicon-based
115
+ view of sentiment. `achieved_sentiment` typically lands within ~0.2 of the
116
+ target rather than hitting it exactly.
117
+ - The rewrite is length-bounded by `num_masks`, so very long sentences are
118
+ usually replaced by something shorter.
119
+ - Because the whole passage is re-encoded per mask fill, generation cost grows
120
+ with `num_masks × beam_size`.
config.json ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "ModernBertForMaskedLM"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "bos_token_id": 50281,
8
+ "classifier_activation": "gelu",
9
+ "classifier_bias": false,
10
+ "classifier_dropout": 0.0,
11
+ "classifier_pooling": "mean",
12
+ "cls_token_id": 50281,
13
+ "decoder_bias": true,
14
+ "deterministic_flash_attn": false,
15
+ "dtype": "float32",
16
+ "embedding_dropout": 0.0,
17
+ "eos_token_id": 50282,
18
+ "global_attn_every_n_layers": 3,
19
+ "gradient_checkpointing": false,
20
+ "hidden_activation": "gelu",
21
+ "hidden_size": 1024,
22
+ "initializer_cutoff_factor": 2.0,
23
+ "initializer_range": 0.02,
24
+ "intermediate_size": 2624,
25
+ "layer_norm_eps": 1e-05,
26
+ "layer_types": [
27
+ "full_attention",
28
+ "sliding_attention",
29
+ "sliding_attention",
30
+ "full_attention",
31
+ "sliding_attention",
32
+ "sliding_attention",
33
+ "full_attention",
34
+ "sliding_attention",
35
+ "sliding_attention",
36
+ "full_attention",
37
+ "sliding_attention",
38
+ "sliding_attention",
39
+ "full_attention",
40
+ "sliding_attention",
41
+ "sliding_attention",
42
+ "full_attention",
43
+ "sliding_attention",
44
+ "sliding_attention",
45
+ "full_attention",
46
+ "sliding_attention",
47
+ "sliding_attention",
48
+ "full_attention",
49
+ "sliding_attention",
50
+ "sliding_attention",
51
+ "full_attention",
52
+ "sliding_attention",
53
+ "sliding_attention",
54
+ "full_attention"
55
+ ],
56
+ "local_attention": 128,
57
+ "max_position_embeddings": 8192,
58
+ "mlp_bias": false,
59
+ "mlp_dropout": 0.0,
60
+ "model_type": "modernbert",
61
+ "norm_bias": false,
62
+ "norm_eps": 1e-05,
63
+ "num_attention_heads": 16,
64
+ "num_hidden_layers": 28,
65
+ "pad_token_id": 50283,
66
+ "position_embedding_type": "absolute",
67
+ "rope_parameters": {
68
+ "full_attention": {
69
+ "rope_theta": 160000.0,
70
+ "rope_type": "default"
71
+ },
72
+ "sliding_attention": {
73
+ "rope_theta": 10000.0,
74
+ "rope_type": "default"
75
+ }
76
+ },
77
+ "sep_token_id": 50282,
78
+ "sparse_pred_ignore_index": -100,
79
+ "sparse_prediction": false,
80
+ "tie_word_embeddings": true,
81
+ "transformers_version": "5.3.0",
82
+ "vocab_size": 50389
83
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:01cc07f8b521219991520866db120d63a888ffc3fd519c471d08d2aabd817f21
3
+ size 1583630940
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ torch>=2.0
2
+ transformers>=4.48
3
+ huggingface-hub>=0.23
4
+ nltk>=3.8
senseshift/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SenseShift: rewrite or extend text at any sentiment from -1.0 to 1.0."""
2
+
3
+ from .pipeline import SenseShift, SenseShiftOutput
4
+ from .text_utils import SENTIMENT_GRID, compute_vader_sentiment, split_sentences
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = [
8
+ "SenseShift",
9
+ "SenseShiftOutput",
10
+ "SENTIMENT_GRID",
11
+ "compute_vader_sentiment",
12
+ "split_sentences",
13
+ ]
senseshift/decoding.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Iterative left-to-right beam search over the masked positions."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import List, Optional, Tuple
6
+
7
+ import torch
8
+
9
+ from .text_utils import clean_generated_text, split_sentences, strip_sentiment_marker
10
+
11
+
12
+ @dataclass
13
+ class Beam:
14
+ text: str
15
+ score: float
16
+
17
+
18
+ def _terminal_token_ids(tokenizer) -> set:
19
+ ids = set()
20
+ for punct in (".", "!", "?"):
21
+ ids.update(tokenizer.encode(punct, add_special_tokens=False))
22
+ return ids
23
+
24
+
25
+ @torch.inference_mode()
26
+ def fill_masks_beam_search(
27
+ model,
28
+ tokenizer,
29
+ masked_text: str,
30
+ sentence_index: int,
31
+ top_k: int = 40,
32
+ beam_size: int = 2,
33
+ max_iters: int = 30,
34
+ alpha: float = 0.7,
35
+ gamma: float = 0.05,
36
+ temperature: float = 0.8,
37
+ min_words: int = 3,
38
+ ) -> Tuple[List[Beam], str]:
39
+ """Fill masks one at a time, keeping ``beam_size`` hypotheses alive.
40
+
41
+ ``alpha`` is the length-normalisation exponent, ``gamma`` penalises beams
42
+ that reuse a token already proposed this step, and a beam finishes early
43
+ once it emits terminal punctuation (after at least ``min_words`` fills).
44
+
45
+ Returns the ranked beams over the *whole* passage plus the rewritten
46
+ sentence extracted from the best beam.
47
+ """
48
+ device = next(model.parameters()).device
49
+ mask_token_id = tokenizer.mask_token_id
50
+ pad_token_id = tokenizer.pad_token_id
51
+ if pad_token_id is None:
52
+ pad_token_id = tokenizer.eos_token_id
53
+ terminal_tokens = _terminal_token_ids(tokenizer)
54
+
55
+ encoded = tokenizer(masked_text, return_tensors="pt").to(device)
56
+ # (ids, cumulative score, tokens filled, finished, last token)
57
+ beams: List[tuple] = [(encoded.input_ids[0], 0.0, 0, False, None)]
58
+
59
+ for _ in range(max_iters):
60
+ active = [i for i, b in enumerate(beams) if (b[0] == mask_token_id).any() and not b[3]]
61
+ if not active:
62
+ break
63
+
64
+ active_beams = [beams[i] for i in active]
65
+ batch_ids = torch.stack([b[0] for b in active_beams]).to(device)
66
+ batch_scores = torch.tensor([b[1] for b in active_beams], device=device)
67
+ batch_filled = torch.tensor([b[2] for b in active_beams], device=device)
68
+ mask_positions = (batch_ids == mask_token_id).int().argmax(dim=1)
69
+
70
+ logits = model(input_ids=batch_ids).logits
71
+ mask_logits = logits[torch.arange(batch_ids.shape[0]), mask_positions] / temperature
72
+
73
+ probs = torch.softmax(mask_logits, dim=-1)
74
+ top_probs, top_tokens = torch.topk(probs, top_k)
75
+ top_probs = top_probs / top_probs.sum(dim=-1, keepdim=True)
76
+ log_p = torch.log(top_probs + 1e-10)
77
+
78
+ candidates = [b for i, b in enumerate(beams) if i not in active]
79
+
80
+ for i in range(len(active_beams)):
81
+ filled = batch_filled[i].item() + 1
82
+ length_norm = ((5 + filled) ** alpha) / ((5 + 1) ** alpha)
83
+ scores = (batch_scores[i] + log_p[i]) / length_norm
84
+
85
+ for j in range(top_k):
86
+ token_id = top_tokens[i, j].item()
87
+ # discourage every beam from picking the same continuation
88
+ penalty = sum(1 for c in candidates if c[4] == token_id)
89
+ score = scores[j].item() - gamma * penalty
90
+
91
+ new_ids = batch_ids[i].clone()
92
+ new_ids[mask_positions[i]] = token_id
93
+
94
+ finished = token_id in terminal_tokens and filled >= min_words
95
+ if finished:
96
+ new_ids[new_ids == mask_token_id] = pad_token_id
97
+
98
+ candidates.append((new_ids, score, filled, finished, token_id))
99
+
100
+ beams = sorted(candidates, key=lambda x: x[1], reverse=True)[:beam_size]
101
+
102
+ ranked = [Beam(tokenizer.decode(b[0], skip_special_tokens=True), float(b[1])) for b in beams]
103
+ best_text = ranked[0].text if ranked else ""
104
+
105
+ sentences_out = split_sentences(best_text)
106
+ if sentence_index < len(sentences_out):
107
+ best_sentence = sentences_out[sentence_index]
108
+ else:
109
+ best_sentence = sentences_out[-1] if sentences_out else best_text
110
+
111
+ return ranked, clean_generated_text(strip_sentiment_marker(best_sentence)).strip()
senseshift/masking.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build the sentiment-annotated, partially masked prompt the model reads.
2
+
3
+ The model was trained on sequences where *every* sentence is prefixed with its
4
+ own control token, e.g.::
5
+
6
+ [0.4] The food arrived quickly. [-0.6] [MASK] [MASK] [MASK] [MASK] .
7
+
8
+ Exactly one sentence carries a control token that disagrees with its text; that
9
+ sentence is masked out and the model must write a replacement that realises the
10
+ requested sentiment while staying consistent with its neighbours.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from typing import List, Optional
16
+
17
+ from .text_utils import (
18
+ compute_vader_sentiment,
19
+ sentiment_token,
20
+ snap_to_grid,
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class MaskedPrompt:
26
+ masked_text: str
27
+ sentences: List[str]
28
+ sentence_index: int
29
+ source_sentiment: float
30
+ target_sentiment: float
31
+ sentence_sentiments: List[float] = field(default_factory=list)
32
+ original_sentence: Optional[str] = None
33
+
34
+
35
+ def build_masked_prompt(
36
+ tokenizer,
37
+ text: str,
38
+ sentence_index: int,
39
+ target_sentiment: float,
40
+ num_masks: Optional[int] = None,
41
+ ) -> MaskedPrompt:
42
+ """Annotate every sentence with its control token and mask sentence ``sentence_index``.
43
+
44
+ ``num_masks=None`` uses the word count of the sentence being replaced, which
45
+ is the training-time convention and keeps the rewrite roughly length-matched.
46
+ """
47
+ sentences, sentiments, _ = compute_vader_sentiment(text)
48
+ if not sentences:
49
+ raise ValueError("Input text contains no sentences.")
50
+ if not 0 <= sentence_index < len(sentences):
51
+ raise IndexError(
52
+ f"sentence_index {sentence_index} out of range for {len(sentences)} sentences."
53
+ )
54
+
55
+ target_sentiment = snap_to_grid(target_sentiment)
56
+ source_sentiment = sentiments[sentence_index]
57
+
58
+ targets = list(sentiments)
59
+ targets[sentence_index] = target_sentiment
60
+
61
+ annotated: List[str] = []
62
+ for i, (sentence, value) in enumerate(zip(sentences, targets)):
63
+ token = sentiment_token(value)
64
+ if i == sentence_index:
65
+ width = len(sentence.split()) if num_masks is None else num_masks
66
+ width = max(1, int(width))
67
+ annotated.append(token + " " + " ".join([tokenizer.mask_token] * width))
68
+ else:
69
+ annotated.append(f"{token} {sentence}")
70
+
71
+ masked_text = " ".join(annotated).replace(" ", " ")
72
+
73
+ return MaskedPrompt(
74
+ masked_text=masked_text,
75
+ sentences=sentences,
76
+ sentence_index=sentence_index,
77
+ source_sentiment=source_sentiment,
78
+ target_sentiment=target_sentiment,
79
+ sentence_sentiments=targets,
80
+ original_sentence=sentences[sentence_index],
81
+ )
82
+
83
+
84
+ def insert_placeholder(
85
+ tokenizer,
86
+ text: str,
87
+ insert_after: Optional[int],
88
+ num_masks: int,
89
+ ) -> tuple[str, int]:
90
+ """Splice an all-mask placeholder sentence into ``text``.
91
+
92
+ Returns the widened text and the index the new sentence occupies, ready to
93
+ be handed to :func:`build_masked_prompt`.
94
+ """
95
+ from .text_utils import split_sentences
96
+
97
+ sentences = split_sentences(text)
98
+ if not sentences:
99
+ raise ValueError("Input text contains no sentences.")
100
+
101
+ if insert_after is None:
102
+ insert_after = len(sentences) - 1
103
+ if not 0 <= insert_after < len(sentences):
104
+ raise IndexError(
105
+ f"sentence_index {insert_after} out of range for {len(sentences)} sentences."
106
+ )
107
+
108
+ new_index = insert_after + 1
109
+ placeholder = " ".join([tokenizer.mask_token] * max(1, int(num_masks))) + " ."
110
+ widened = list(sentences)
111
+ widened.insert(new_index, placeholder)
112
+ return " ".join(widened), new_index
senseshift/pipeline.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SenseShift — sentiment-controlled sentence rewriting and insertion."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import random as _random
6
+ from dataclasses import asdict, dataclass, field
7
+ from typing import Any, Dict, List, Optional, Union
8
+
9
+ import torch
10
+
11
+ from .decoding import Beam, fill_masks_beam_search
12
+ from .masking import build_masked_prompt, insert_placeholder
13
+ from .text_utils import (
14
+ choose_random_sentiment,
15
+ score_sentence,
16
+ snap_to_grid,
17
+ split_sentences,
18
+ )
19
+
20
+ CONFIG_FILE = "senseshift_config.json"
21
+
22
+ DEFAULT_GENERATION: Dict[str, Any] = {
23
+ "top_k": 40,
24
+ "beam_size": 2,
25
+ "max_iters": 30,
26
+ "alpha": 0.7,
27
+ "gamma": 0.05,
28
+ "temperature": 0.8,
29
+ "min_words": 3,
30
+ "add_num_masks": 12,
31
+ }
32
+
33
+ Sentiment = Union[None, str, float, int]
34
+
35
+
36
+ @dataclass
37
+ class SenseShiftOutput:
38
+ """Result of one edit."""
39
+
40
+ text: str
41
+ """The full passage after the edit."""
42
+
43
+ sentence: str
44
+ """The sentence SenseShift wrote."""
45
+
46
+ sentences: List[str]
47
+ sentence_index: int
48
+ generation_mode: str
49
+ target_sentiment: float
50
+ source_sentiment: Optional[float]
51
+ achieved_sentiment: float
52
+ original_sentence: Optional[str] = None
53
+ beams: List[Beam] = field(default_factory=list)
54
+
55
+ def __str__(self) -> str: # so print(out) shows the passage
56
+ return self.text
57
+
58
+ def to_dict(self) -> Dict[str, Any]:
59
+ d = asdict(self)
60
+ d["beams"] = [asdict(b) for b in self.beams]
61
+ return d
62
+
63
+
64
+ class SenseShift:
65
+ """Rewrite or extend a passage at a chosen sentiment on a [-1, 1] scale.
66
+
67
+ >>> shifter = SenseShift.from_pretrained("shawhed/SenseShift-large")
68
+ >>> out = shifter.generate(text, sentence_index=1, sentiment=-0.8)
69
+ >>> print(out.text)
70
+ """
71
+
72
+ def __init__(self, model, tokenizer, generation_defaults: Optional[Dict[str, Any]] = None):
73
+ self.model = model
74
+ self.tokenizer = tokenizer
75
+ self.generation_defaults = {**DEFAULT_GENERATION, **(generation_defaults or {})}
76
+ self._validate_vocabulary()
77
+
78
+ # ------------------------------------------------------------------ load
79
+
80
+ @classmethod
81
+ def from_pretrained(
82
+ cls,
83
+ model_id: str,
84
+ device: Optional[str] = None,
85
+ dtype: Optional["torch.dtype"] = None,
86
+ **kwargs,
87
+ ) -> "SenseShift":
88
+ """Load weights, tokenizer and generation defaults from the Hub or a local path."""
89
+ from transformers import AutoModelForMaskedLM, AutoTokenizer
90
+
91
+ if device is None:
92
+ device = "cuda" if torch.cuda.is_available() else "cpu"
93
+
94
+ tokenizer = AutoTokenizer.from_pretrained(model_id, **kwargs)
95
+ model = AutoModelForMaskedLM.from_pretrained(model_id, dtype=dtype, **kwargs)
96
+ model.to(device).eval()
97
+
98
+ return cls(model, tokenizer, generation_defaults=_load_config(model_id, **kwargs))
99
+
100
+ def _validate_vocabulary(self) -> None:
101
+ from .text_utils import SENTIMENT_GRID, sentiment_token
102
+
103
+ vocab = self.tokenizer.get_vocab()
104
+ missing = [sentiment_token(v) for v in SENTIMENT_GRID if sentiment_token(v) not in vocab]
105
+ if missing:
106
+ raise ValueError(
107
+ "This tokenizer is missing SenseShift control tokens "
108
+ f"({', '.join(missing[:5])}{'…' if len(missing) > 5 else ''}). "
109
+ "Load a SenseShift checkpoint, not the base ModernBERT."
110
+ )
111
+ if self.tokenizer.mask_token_id is None:
112
+ raise ValueError("SenseShift needs a masked-language-model tokenizer with a mask token.")
113
+
114
+ # -------------------------------------------------------------- generate
115
+
116
+ def generate(
117
+ self,
118
+ text: str,
119
+ generation_mode: str = "rewrite",
120
+ sentiment: Sentiment = None,
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
+
128
+ Args:
129
+ text: The passage to edit.
130
+ generation_mode: ``"rewrite"`` replaces the sentence at
131
+ ``sentence_index``; ``"add"`` writes a new sentence and splices
132
+ it in directly after ``sentence_index``.
133
+ sentiment: ``None`` keeps the sentiment already there (for ``"add"``,
134
+ the sentiment of the sentence being appended to); ``"random"``
135
+ draws a value from the 0.1 grid, excluding the current one; a
136
+ number in ``[-1, 1]`` is used as-is, snapped to the nearest 0.1.
137
+ sentence_index: Which sentence to act on. Defaults to a random
138
+ sentence for ``"rewrite"`` and to the last sentence for ``"add"``.
139
+ num_masks: How many mask slots to give the model, i.e. roughly the
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.
148
+ """
149
+ if generation_mode not in ("rewrite", "add"):
150
+ raise ValueError(
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
+
161
+ sentences = split_sentences(text)
162
+ if not sentences:
163
+ raise ValueError("Input text contains no sentences.")
164
+
165
+ anchor = self._resolve_index(sentence_index, len(sentences), generation_mode, rng)
166
+
167
+ if generation_mode == "rewrite":
168
+ source = score_sentence(sentences[anchor])
169
+ target = self._resolve_sentiment(sentiment, source, rng)
170
+ prompt = build_masked_prompt(
171
+ self.tokenizer, text, anchor, target, num_masks=num_masks
172
+ )
173
+ else:
174
+ source = score_sentence(sentences[anchor])
175
+ target = self._resolve_sentiment(sentiment, source, rng)
176
+ width = params["add_num_masks"] if num_masks is None else num_masks
177
+ widened, anchor = insert_placeholder(self.tokenizer, text, anchor, width)
178
+ prompt = build_masked_prompt(
179
+ self.tokenizer, widened, anchor, target, num_masks=width
180
+ )
181
+
182
+ beams, new_sentence = fill_masks_beam_search(
183
+ self.model,
184
+ self.tokenizer,
185
+ prompt.masked_text,
186
+ sentence_index=anchor,
187
+ top_k=params["top_k"],
188
+ beam_size=params["beam_size"],
189
+ max_iters=params["max_iters"],
190
+ alpha=params["alpha"],
191
+ gamma=params["gamma"],
192
+ temperature=params["temperature"],
193
+ min_words=params["min_words"],
194
+ )
195
+
196
+ if not new_sentence:
197
+ if generation_mode == "add":
198
+ raise RuntimeError("Model produced an empty sentence; nothing was inserted.")
199
+ new_sentence = sentences[anchor] # fall back to the original
200
+
201
+ new_sentences = list(sentences)
202
+ if generation_mode == "rewrite":
203
+ new_sentences[anchor] = new_sentence
204
+ original = sentences[anchor]
205
+ else:
206
+ new_sentences.insert(anchor, new_sentence)
207
+ original = None
208
+
209
+ return SenseShiftOutput(
210
+ text=" ".join(new_sentences),
211
+ sentence=new_sentence,
212
+ sentences=new_sentences,
213
+ sentence_index=anchor,
214
+ generation_mode=generation_mode,
215
+ target_sentiment=target,
216
+ source_sentiment=source if generation_mode == "rewrite" else None,
217
+ achieved_sentiment=score_sentence(new_sentence),
218
+ original_sentence=original,
219
+ beams=beams,
220
+ )
221
+
222
+ # --------------------------------------------------------------- helpers
223
+
224
+ @staticmethod
225
+ def _resolve_index(
226
+ sentence_index: Optional[int], n: int, generation_mode: str, rng
227
+ ) -> int:
228
+ if sentence_index is None:
229
+ return rng.randrange(n) if generation_mode == "rewrite" else n - 1
230
+ if sentence_index < 0:
231
+ sentence_index += n
232
+ if not 0 <= sentence_index < n:
233
+ raise IndexError(f"sentence_index out of range for {n} sentences.")
234
+ return sentence_index
235
+
236
+ @staticmethod
237
+ def _resolve_sentiment(sentiment: Sentiment, source: float, rng) -> float:
238
+ if sentiment is None:
239
+ return snap_to_grid(source)
240
+ if isinstance(sentiment, str):
241
+ if sentiment.lower() != "random":
242
+ raise ValueError(
243
+ f"sentiment string must be 'random', got {sentiment!r}"
244
+ )
245
+ return choose_random_sentiment(exclude=source, rng=rng)
246
+ if isinstance(sentiment, bool) or not isinstance(sentiment, (int, float)):
247
+ raise TypeError(
248
+ "sentiment must be None, 'random', or a number in [-1, 1]; "
249
+ f"got {type(sentiment).__name__}"
250
+ )
251
+ if not -1.0 <= float(sentiment) <= 1.0:
252
+ raise ValueError(f"sentiment must lie in [-1, 1], got {sentiment}")
253
+ return snap_to_grid(sentiment)
254
+
255
+
256
+ def _load_config(model_id: str, **kwargs) -> Dict[str, Any]:
257
+ """Read ``senseshift_config.json`` from a local dir or the Hub; empty if absent."""
258
+ import os
259
+
260
+ local = os.path.join(model_id, CONFIG_FILE)
261
+ if os.path.isfile(local):
262
+ with open(local, encoding="utf-8") as fh:
263
+ return json.load(fh).get("generation", {})
264
+
265
+ try:
266
+ from huggingface_hub import hf_hub_download
267
+ from huggingface_hub.errors import EntryNotFoundError
268
+
269
+ path = hf_hub_download(
270
+ model_id,
271
+ CONFIG_FILE,
272
+ revision=kwargs.get("revision"),
273
+ token=kwargs.get("token"),
274
+ )
275
+ except Exception:
276
+ return {}
277
+
278
+ with open(path, encoding="utf-8") as fh:
279
+ return json.load(fh).get("generation", {})
senseshift/text_utils.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sentence segmentation, VADER scoring and output cleanup.
2
+
3
+ Self-contained copies of the helpers the research repo keeps in
4
+ ``generate_utils.py``, so the released package does not depend on it.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from functools import lru_cache
10
+ from typing import List, Sequence, Tuple
11
+
12
+ # The control vocabulary the model was trained with: 21 tokens on a 0.1 grid.
13
+ SENTIMENT_GRID: Tuple[float, ...] = tuple(round(i / 10, 1) + 0.0 for i in range(-10, 11))
14
+
15
+
16
+ def sentiment_token(value: float) -> str:
17
+ """Map a sentiment value to the special token the model expects."""
18
+ return f"[{snap_to_grid(value)}]"
19
+
20
+
21
+ def snap_to_grid(value: float) -> float:
22
+ """Clamp to [-1, 1] and round to the nearest 0.1 (never returns -0.0)."""
23
+ value = float(value)
24
+ if value != value: # NaN
25
+ raise ValueError("sentiment must be a real number, got NaN")
26
+ value = max(-1.0, min(1.0, value))
27
+ return round(value, 1) + 0.0
28
+
29
+
30
+ @lru_cache(maxsize=1)
31
+ def _analyzer():
32
+ import nltk
33
+ from nltk.sentiment import SentimentIntensityAnalyzer
34
+
35
+ try:
36
+ nltk.data.find("sentiment/vader_lexicon.zip")
37
+ except LookupError:
38
+ nltk.download("vader_lexicon", quiet=True)
39
+ return SentimentIntensityAnalyzer()
40
+
41
+
42
+ def split_sentences(text: str) -> List[str]:
43
+ parts = re.split(r"(?<=[.!?])\s+", text.strip())
44
+ return [p.strip() for p in parts if p.strip()]
45
+
46
+
47
+ def score_sentence(sentence: str) -> float:
48
+ return snap_to_grid(_analyzer().polarity_scores(sentence)["compound"])
49
+
50
+
51
+ def compute_vader_sentiment(text: str) -> Tuple[List[str], List[float], float]:
52
+ """Return (sentences, per-sentence sentiment on the 0.1 grid, overall)."""
53
+ sentences = split_sentences(text)
54
+ sentiments = [score_sentence(s) for s in sentences]
55
+ overall = snap_to_grid(_analyzer().polarity_scores(text)["compound"])
56
+ return sentences, sentiments, overall
57
+
58
+
59
+ def strip_sentiment_marker(text: str) -> str:
60
+ """Drop a leading ``[0.3]`` style control token."""
61
+ return re.sub(r"^\s*\[[+-]?\d+(\.\d+)?\]\s*", "", text)
62
+
63
+
64
+ def clean_generated_text(text: str) -> str:
65
+ """Detokenisation cleanup for text decoded out of the MLM."""
66
+ cleaned = re.sub(r"\b(\w+)\s+##(\w+)", r"\1\2", text)
67
+ cleaned = re.sub(r"<[^>]*>", "", cleaned)
68
+ cleaned = re.sub(r"\[[+-]?\d+(\.\d+)?\]", " ", cleaned)
69
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
70
+
71
+ parts = [p.strip() for p in re.split(r"\s{2,}", cleaned) if p.strip()]
72
+ if not parts:
73
+ return cleaned
74
+
75
+ result_parts = []
76
+ for p in parts:
77
+ if p and p[-1] not in ".!?":
78
+ p += "."
79
+ result_parts.append(p)
80
+
81
+ out = " ".join(result_parts)
82
+ out = re.sub(r"\s+", " ", out).strip()
83
+ out = re.sub(r"\s+([,.;:!?])", r"\1", out)
84
+ out = re.sub(r"\s*'\s*", "'", out)
85
+ out = re.sub(r"\.\.+", ".", out)
86
+ out = re.sub(r'"', "", out)
87
+ return out
88
+
89
+
90
+ def choose_random_sentiment(exclude: float | None = None, rng=None) -> float:
91
+ """Pick a grid value, optionally excluding the current one."""
92
+ import random as _random
93
+
94
+ rng = rng or _random
95
+ options: Sequence[float] = SENTIMENT_GRID
96
+ if exclude is not None:
97
+ exclude = snap_to_grid(exclude)
98
+ options = [v for v in SENTIMENT_GRID if v != exclude]
99
+ return float(rng.choice(list(options)))
senseshift_config.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "senseshift_version": "0.1.0",
3
+ "base_model": "answerdotai/ModernBERT-large",
4
+ "control_type": "text_token",
5
+ "sentiment_scorer": "vader",
6
+ "sentiment_grid": [
7
+ -1.0,
8
+ -0.9,
9
+ -0.8,
10
+ -0.7,
11
+ -0.6,
12
+ -0.5,
13
+ -0.4,
14
+ -0.3,
15
+ -0.2,
16
+ -0.1,
17
+ 0.0,
18
+ 0.1,
19
+ 0.2,
20
+ 0.3,
21
+ 0.4,
22
+ 0.5,
23
+ 0.6,
24
+ 0.7,
25
+ 0.8,
26
+ 0.9,
27
+ 1.0
28
+ ],
29
+ "generation": {
30
+ "top_k": 40,
31
+ "beam_size": 2,
32
+ "max_iters": 30,
33
+ "alpha": 0.7,
34
+ "gamma": 0.05,
35
+ "temperature": 0.8,
36
+ "min_words": 3,
37
+ "add_num_masks": 12
38
+ }
39
+ }
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "clean_up_tokenization_spaces": true,
4
+ "cls_token": "[CLS]",
5
+ "extra_special_tokens": [
6
+ "[-1.0]",
7
+ "[-0.9]",
8
+ "[-0.8]",
9
+ "[-0.7]",
10
+ "[-0.6]",
11
+ "[-0.5]",
12
+ "[-0.4]",
13
+ "[-0.3]",
14
+ "[-0.2]",
15
+ "[-0.1]",
16
+ "[0.0]",
17
+ "[0.1]",
18
+ "[0.2]",
19
+ "[0.3]",
20
+ "[0.4]",
21
+ "[0.5]",
22
+ "[0.6]",
23
+ "[0.7]",
24
+ "[0.8]",
25
+ "[0.9]",
26
+ "[1.0]"
27
+ ],
28
+ "is_local": true,
29
+ "mask_token": "[MASK]",
30
+ "max_length": 512,
31
+ "model_input_names": [
32
+ "input_ids",
33
+ "attention_mask"
34
+ ],
35
+ "model_max_length": 8192,
36
+ "pad_to_multiple_of": null,
37
+ "pad_token": "[PAD]",
38
+ "pad_token_type_id": 0,
39
+ "padding_side": "right",
40
+ "sep_token": "[SEP]",
41
+ "stride": 0,
42
+ "tokenizer_class": "PreTrainedTokenizerFast",
43
+ "truncation_side": "right",
44
+ "truncation_strategy": "longest_first",
45
+ "unk_token": "[UNK]"
46
+ }