File size: 10,790 Bytes
7d7be4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481e612
7d7be4a
481e612
7d7be4a
2053c47
7d7be4a
 
2053c47
 
 
5b2d9fd
7d7be4a
2053c47
 
 
 
 
 
 
7d7be4a
481e612
9a87d6b
2053c47
481e612
2053c47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d7be4a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2053c47
 
9a87d6b
2053c47
9a87d6b
 
 
481e612
2053c47
 
 
 
 
7d7be4a
2053c47
 
7d7be4a
2053c47
7d7be4a
2053c47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d7be4a
 
481e612
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d7be4a
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
---
base_model: TextMachineProject/NewsBERT_1800-1920
library_name: transformers
tags:
  - bert
  - masked-language-modeling
  - historical-nlp
  - temporal-language-model
---

# NewsBERT_1800-1920-Temporal

A fine-tuned version of [TextMachineProject/NewsBERT_1800-1920](https://huggingface.co/TextMachineProject/NewsBERT_1800-1920), conditioned on each item's publication year as a continuous input. A single model was trained across the full 1800-1920 span of the original NewsBERT_1800-1920's dataset, with each training example's year injected as a sinusoidal (Fourier) feature added to the token embeddings. Nearby years produce nearby embeddings by construction, so the model can be queried at any year in-range.

## Architecture

- **Base**: `TextMachineProject/NewsBERT_1800-1920` (BERT-base architecture)
- **Time conditioning**: a small `ContinuousTimeEmbedding` module maps a
  normalized year to a `hidden_size`-dimensional vector via sinusoidal
  features at 24 log-spaced frequencies, spanning:
  - lowest frequency: 1 cycle over the full 120-year corpus span
  - highest frequency: 1 cycle per 5 years
- This vector is added to every token's input embedding, then passed
  through a `LayerNorm` before entering the transformer stack (this re-normalization step
  was added after an earlier version without it let the time signal's magnitude
  dominate the token embeddings it was added to, which suppressed learning).

You need the accompanying `continuous_time_embedding.py` in this repo to
load this model, as it is **not** loadable via a plain
`AutoModelForMaskedLM.from_pretrained(...)` call, since the time-injection
logic is outside the standard BERT forward pass.

## Training data

9.28M items (`text`, `year`) from the LwM and HMD collections (see [TextMachineProject/NewsBERT_1800-1920](https://huggingface.co/TextMachineProject/NewsBERT_1800-1920)), spanning 1800-1920, split into
overlapping 126-token windows (stride 96), i.e. 94.0M training windows.

## Training procedure

- 1 epoch, batch size 256, linear LR decay, 500 warmup steps
- Learning rate: `2e-5` for the base model's existing (pretrained) layers, 
and a higher rate of `1e-4` for the ContinuousTimeEmbedding module 
since that module is new and starts from scratch
- bf16 mixed precision

### Hardware / environment

This research utilised Queen Mary's Apocrita HPC facility, supported by
QMUL Research-IT. http://doi.org/10.5281/zenodo.438045

- Single GPU per training job
- GPU: NVIDIA A100-PCIE-40GB (40GB VRAM)
- CUDA: 12.6 (as built into the `torch` version used)
- Python 3.11.7 (GCC 12.2.0 build)
- `torch` 2.11.0+cu126
- `transformers` 5.12.1

## Example usage

### Load the model and the companion module
```python
import torch
import torch.nn.functional as F
import math
from huggingface_hub import hf_hub_download
import shutil, os

REPO_ID = "TextMachineProject/NewsBERT_1800-1920-Temporal"

# Download the companion module from the model repo and make it importable
py_path = hf_hub_download(repo_id=REPO_ID, filename="continuous_time_embedding.py")
shutil.copy(py_path, os.path.basename(py_path))

from continuous_time_embedding import load_continuous_time_model

tokenizer, model = load_continuous_time_model(REPO_ID)
device = next(model.parameters()).device
```
### Basic functions to get top mask predictions and token probability

```python
def predict_masked(sentence, year, top_k=5):
    enc = tokenizer(sentence, return_tensors="pt").to(device)
    mask_pos = (enc["input_ids"][0] == tokenizer.mask_token_id).nonzero(as_tuple=True)[0]
    if len(mask_pos) == 0:
        raise ValueError(f"No [MASK] found in: {sentence!r}")
    mask_pos = mask_pos[0].item()

    embeddings_module = model.get_input_embeddings_module()
    tok_embeds = embeddings_module(enc["input_ids"])
    time_vec = model.time_embed(torch.tensor([float(year)]).to(device)).unsqueeze(1)
    tok_embeds = model.post_inject_norm(tok_embeds + time_vec)

    out = model.model(inputs_embeds=tok_embeds, attention_mask=enc["attention_mask"])
    probs = F.softmax(out.logits[0, mask_pos].float(), dim=-1)
    top = torch.topk(probs, top_k)
    return [(tokenizer.convert_ids_to_tokens([tid.item()])[0], p.item()) for tid, p in zip(top.indices, top.values)]


def get_target_word_probability(sentence, target_word, year):
    target_ids = tokenizer.encode(target_word, add_special_tokens=False)
    mask_str = " ".join([tokenizer.mask_token] * len(target_ids))
    masked_sentence = sentence.replace(target_word, mask_str, 1)

    enc = tokenizer(masked_sentence, return_tensors="pt").to(device)
    mask_positions = (enc["input_ids"][0] == tokenizer.mask_token_id).nonzero(as_tuple=True)[0].tolist()

    embeddings_module = model.get_input_embeddings_module()
    tok_embeds = embeddings_module(enc["input_ids"])
    time_vec = model.time_embed(torch.tensor([float(year)]).to(device)).unsqueeze(1)
    tok_embeds = model.post_inject_norm(tok_embeds + time_vec)

    out = model.model(inputs_embeds=tok_embeds, attention_mask=enc["attention_mask"])

    logprob_sum = 0.0
    for pos, tgt_id in zip(mask_positions, target_ids):
        log_probs = F.log_softmax(out.logits[0, pos].float(), dim=-1)
        logprob_sum += log_probs[tgt_id].item()

    prob = math.exp(logprob_sum) if len(target_ids) == 1 else None
    return prob, logprob_sum
```

### Check changes in top mask prediction or in token probability

```python
### Example 1: fill-mask, top predictions at different years
sentence = "The [MASK] arrived on time."
for year in [1800, 1810, 1820, 1830, 1840, 1850, 1860, 1870, 1880, 1890, 1900, 1910, 1920]:
    preds = predict_masked(sentence, year, top_k=5)
    pred_str = ", ".join(f"{tok}({p:.3f})" for tok, p in preds)
    print(f"year={year}: {pred_str}")


# Example 2: probability of a specific word, across years
sentence = "The train arrived at the station on time"
for year in [1800, 1810, 1820, 1830, 1840, 1850, 1860, 1870, 1880, 1890, 1900, 1910, 1920]:
    prob, logprob_sum = get_target_word_probability(sentence, "train", year)
    if prob is not None:
        print(f"year={year}: P(train)={prob:.10f}  (logprob_sum={logprob_sum:.4f})")
    else:
        print(f"year={year}: logprob_sum={logprob_sum:.4f} (multi-subtoken word, no single probability)")
```


Example outputs:
```
year=1800: express(0.048), steamer(0.043), mail(0.031), train(0.018), news(0.014)
year=1810: express(0.064), steamer(0.044), mail(0.035), train(0.026), news(0.015)
year=1820: express(0.068), steamer(0.044), mail(0.035), train(0.032), news(0.017)
year=1830: express(0.078), steamer(0.043), mail(0.038), train(0.033), news(0.019)
year=1840: express(0.087), steamer(0.040), train(0.039), mail(0.038), news(0.023)
year=1850: express(0.081), train(0.042), steamer(0.039), mail(0.038), news(0.024)
year=1860: express(0.079), train(0.042), mail(0.038), steamer(0.035), news(0.029)
year=1870: express(0.080), train(0.050), mail(0.037), news(0.034), telegram(0.032)
year=1880: express(0.069), train(0.051), news(0.035), mail(0.035), telegram(0.032)
year=1890: express(0.063), train(0.052), news(0.039), telegram(0.034), mail(0.034)
year=1900: express(0.059), train(0.048), news(0.046), telegram(0.038), mail(0.033)
year=1910: express(0.054), news(0.052), train(0.047), telegram(0.039), mail(0.031)
year=1920: news(0.057), express(0.049), train(0.044), telegram(0.040), mail(0.030)
```

```
year=1800: P(train)=0.2596983756  (logprob_sum=-1.3482)
year=1810: P(train)=0.3239273912  (logprob_sum=-1.1272)
year=1820: P(train)=0.3619919056  (logprob_sum=-1.0161)
year=1830: P(train)=0.3642843769  (logprob_sum=-1.0098)
year=1840: P(train)=0.3824294963  (logprob_sum=-0.9612)
year=1850: P(train)=0.3897419142  (logprob_sum=-0.9423)
year=1860: P(train)=0.3831270530  (logprob_sum=-0.9594)
year=1870: P(train)=0.3918517235  (logprob_sum=-0.9369)
year=1880: P(train)=0.3810631289  (logprob_sum=-0.9648)
year=1890: P(train)=0.3701365410  (logprob_sum=-0.9939)
year=1900: P(train)=0.3599881482  (logprob_sum=-1.0217)
year=1910: P(train)=0.3460587446  (logprob_sum=-1.0611)
year=1920: P(train)=0.3430851355  (logprob_sum=-1.0698)
```

### Check against known historical events

Visualize changes in probability of particular tokens and compare against historical events 
which should correspond to peak in probabilities (either one-off for a specific year or as the start of
a continuous rise in probability thereafter, e.g. due to increased discourse around it in the newspapers).

```python
event_tests = [
    ("The garrison at lucknow was relieved after a long siege.", "lucknow", 1857),
    ("President lincoln was assassinated.", "lincoln", 1865),
    ("After a long siege, the fortress of sebastopol finally fell.", "sebastopol", 1855),
    ("President mckinley was assassinated.", "mckinley", 1901),
    ("The queen has died.", "died", 1901)]

test_years = list(range(1800, 1921, 5))
fig, axes = plt.subplots(len(event_tests), 1, figsize=(11, 4.5 * len(EVENT_TESTS)))

for ax, (sentence, target_word, expected_year) in zip(axes, event_tests):
    masked_sentence = sentence.replace(target_word, tokenizer.mask_token, 1)
    n_subtokens = len(tokenizer.encode(target_word, add_special_tokens=False))
    is_single_token = n_subtokens == 1

    results = [get_target_word_probability(sentence, target_word, year) for year in test_years]
    plot_vals = [prob if is_single_token else logprob_sum for prob, logprob_sum in results]
    for year, (prob, logprob_sum) in zip(test_years, results):
        print(f"{sentence} | year={year}: " + (f"P({target_word})={prob:.6f}" if is_single_token else f"logprob_sum={logprob_sum:.4f}"))

    event_prob, event_logprob = get_target_word_probability(sentence, target_word, expected_year)
    event_val = event_prob if is_single_token else event_logprob

    ax.plot(test_years, plot_vals, marker="o")
    ax.scatter([expected_year], [event_val], marker="*", s=120, color="red", zorder=5, label=f"at {expected_year}")
    ax.axvline(expected_year, color="red", linestyle="--", alpha=0.4)
    ax.set_title(f"{'P' if is_single_token else 'log P'}({target_word!r}) across years\n\"{sentence}\"")
    ax.set_xlabel("queried year")
    ax.set_ylabel("probability" if is_single_token else "log probability")
    xticks = sorted(set(test_years) | {expected_year})
    ax.set_xticks(xticks)
    ax.set_xticklabels(xticks, rotation=45, ha="right")
    ax.legend()
    ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()
```

Example output:

![Event spike test](event_spike_test.png)

## Known limitations

- Trained for 1 epoch only. Treat results as preliminary.
- No year/decade-reweighting was used for this specific run, so the model's
  competence will not be uniform across the full 1800-1920 span and will reflect
  whatever the skewed training corpus's year distribution is.