Skip to content

Commit 33b2a18

Browse files
polmsvlandeg
andauthored
Initial version of polar embeddings (#76)
* Initial version of polar embeddings Couple of issues: This is a stateful component, but it doesn't train. So maybe it shouldn't be a tutorial project, but somewhere else? I used the IMDB reviews dataset from Kaggle for this but it looks like there's no public URL to download the same version of the data. The upstream URL provided at Kaggle has the same data, but every tiny review gets its own file so the overhead takes it from 60MB to 480MB on disk. The dataset is too big anyway, so maybe there's a better alternative? To calculate polar axes properly, the reference words should be averaged with the nearest words in the vocab, but I'm not doing that yet. * Get average of neighbor vectors * Change line width * Apply suggestions from code review Co-authored-by: Sofie Van Landeghem <svlandeg@users.noreply.github.com> * Move to pipelines * Remove spurious comment * Add README, update assets stuff, expand description * Update README Co-authored-by: Sofie Van Landeghem <svlandeg@users.noreply.github.com>
1 parent 553f7fc commit 33b2a18

6 files changed

Lines changed: 288 additions & 1 deletion

File tree

‎pipelines/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<a href="https://explosion.ai"><img src="https://explosion.ai/assets/img/logo.svg" width="125" height="125" align="right" /></a>
22

3-
# 🪐 Project Templates: Pipelines (10)
3+
# 🪐 Project Templates: Pipelines (11)
44

55
| Template | Description |
66
| --- | --- |
@@ -10,6 +10,7 @@
1010
| [`ner_wikiner`](ner_wikiner) | Named Entity Recognition (WikiNER) |
1111
| [`parser_demo`](parser_demo) | Demo Dependency Parser |
1212
| [`parser_intent_demo`](parser_intent_demo) | Demo Intent Parser (Dependency Parser) |
13+
| [`polar_component`](polar_component) | Polar Component |
1314
| [`tagger_parser_predicted_annotations`](tagger_parser_predicted_annotations) | Using Predicted Annotations in Subsequent Components |
1415
| [`tagger_parser_ud`](tagger_parser_ud) | Part-of-speech Tagging & Dependency Parsing (Universal Dependencies) |
1516
| [`textcat_demo`](textcat_demo) | Demo Textcat (Text Classification) |
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<!-- SPACY PROJECT: AUTO-GENERATED DOCS START (do not remove) -->
2+
3+
# 🪐 spaCy Project: Polar Component
4+
5+
This example project shows how to implement a simple stateful component to
6+
score docs on semantic poles.
7+
8+
The method here is based on SemAxis from [An et al
9+
2018](https://arxiv.org/abs/1806.05521). The basic idea is that given a set
10+
of word vectors and some seed poles, like "bad-good", it's possible to
11+
calculate reference vectors. The distance of document vectors from those
12+
reference vectors is like a sentiment or polar score of the document. While
13+
not as sophisticated as a trained model, it's easy to test with existing data.
14+
15+
If you use enough poles, you can use the scores as semantic vectors that can
16+
make downstream tasks explainable. This is explored in the SemAxis paper as
17+
well as [Mathew et al 2020](https://arxiv.org/abs/2001.09876), "The Polar
18+
Framework". (Incorporating semantic vectors as features in a spaCy model is
19+
left as an exercise for the reader.)
20+
21+
**Note:** Because the data is hosted on Kaggle, it can't be automatically
22+
downloaded by `spacy project assets`, so you'll have to download it yourself.
23+
See [the assets section of this README](#assets) for the link.
24+
25+
26+
## 📋 project.yml
27+
28+
The [`project.yml`](project.yml) defines the data assets required by the
29+
project, as well as the available commands and workflows. For details, see the
30+
[spaCy projects documentation](https://spacy.io/usage/projects).
31+
32+
### ⏯ Commands
33+
34+
The following commands are defined by the project. They
35+
can be executed using [`spacy project run [name]`](https://spacy.io/api/cli#project-run).
36+
Commands are only re-run if their inputs have changed.
37+
38+
| Command | Description |
39+
| --- | --- |
40+
| `evaluate` | Check output on sample data |
41+
42+
### ⏭ Workflows
43+
44+
The following workflows are defined by the project. They
45+
can be executed using [`spacy project run [name]`](https://spacy.io/api/cli#project-run)
46+
and will run the specified commands in order. Commands are only re-run if their
47+
inputs have changed.
48+
49+
| Workflow | Steps |
50+
| --- | --- |
51+
| `all` | `evaluate` |
52+
53+
### 🗂 Assets
54+
55+
The following assets are defined by the project. They can
56+
be fetched by running [`spacy project assets`](https://spacy.io/api/cli#project-assets)
57+
in the project directory.
58+
59+
| File | Source | Description |
60+
| --- | --- | --- |
61+
| `assets/IMDB Dataset.csv` | Local | IMDB Review Corpus. Download from [Kaggle](https://www.kaggle.com/lakshmi25npathi/imdb-dataset-of-50k-movie-reviews). |
62+
63+
<!-- SPACY PROJECT: AUTO-GENERATED DOCS END (do not remove) -->
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
title: "Polar Component"
2+
description: |
3+
This example project shows how to implement a simple stateful component to
4+
score docs on semantic poles.
5+
6+
The method here is based on SemAxis from [An et al
7+
2018](https://arxiv.org/abs/1806.05521). The basic idea is that given a set
8+
of word vectors and some seed poles, like "bad-good", it's possible to
9+
calculate reference vectors. The distance of document vectors from those
10+
reference vectors is like a sentiment or polar score of the document. While
11+
not as sophisticated as a trained model, it's easy to test with existing data.
12+
13+
If you use enough poles, you can use the scores as semantic vectors that can
14+
make downstream tasks explainable. This is explored in the SemAxis paper as
15+
well as [Mathew et al 2020](https://arxiv.org/abs/2001.09876), "The Polar
16+
Framework". (Incorporating semantic vectors as features in a spaCy model is
17+
left as an exercise for the reader.)
18+
19+
**Note:** Because the data is hosted on Kaggle, it can't be automatically
20+
downloaded by `spacy project assets`, so you'll have to download it yourself.
21+
See [the assets section of this README](#assets) for the link.
22+
23+
vars:
24+
reviews: "assets/IMDB Dataset.csv"
25+
26+
# These are the directories that the project needs. The project CLI will make
27+
# sure that they always exist.
28+
directories: ["scripts", "assets"]
29+
30+
# Assets that should be downloaded or available in the directory. You can replace
31+
# this with your own input data.
32+
assets:
33+
- dest: ${vars.reviews}
34+
description: "IMDB Review Corpus. Download from [Kaggle](https://www.kaggle.com/lakshmi25npathi/imdb-dataset-of-50k-movie-reviews)."
35+
36+
workflows:
37+
all:
38+
- evaluate
39+
40+
# Project commands, specified in a style similar to CI config files (e.g. Azure
41+
# pipelines). The name is the command name that lets you trigger the command
42+
# via "spacy project run [command] [path]". The help message is optional and
43+
# shown when executing "spacy project run [optional command] [path] --help".
44+
commands:
45+
- name: "evaluate"
46+
help: "Check output on sample data"
47+
script:
48+
- "python ./scripts/evaluate.py"
49+
deps:
50+
- ${vars.reviews}

‎pipelines/polar_component/scripts/__init__.py‎

Whitespace-only changes.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import spacy
2+
import polar_pipe
3+
import csv
4+
import textwrap
5+
6+
7+
def create_polar_pipeline():
8+
"""Create a pipeline with a polar component."""
9+
# We only need vectors
10+
nlp = spacy.load(
11+
"en_core_web_md", disable=["tok2vec", "parser", "tagger", "ner", "lemmatizer"]
12+
)
13+
14+
polar = nlp.add_pipe("polar")
15+
16+
# now add some axes
17+
polar.add_axis("science", "magic")
18+
polar.add_axis("serious", "funny")
19+
polar.add_axis("heartwarming", "creepy")
20+
polar.add_axis("bad", "good")
21+
return nlp
22+
23+
def format_review(text, truncate=3):
24+
lines = textwrap.wrap(text)
25+
return '\n'.join(lines[:truncate])
26+
27+
def check_axes(nlp, nn=3):
28+
"""Given a pipeline with a polar component, check the IMDB dataset."""
29+
docs = []
30+
31+
with open("assets/IMDB Dataset.csv") as csvfile:
32+
reader = csv.reader(csvfile)
33+
next(reader)
34+
for row in reader:
35+
docs.append(nlp(row[0]))
36+
37+
# XXX Limit for faster testing iteration
38+
if len(docs) > 10000:
39+
break
40+
41+
# now print the reviews at polar extremes
42+
polar = nlp.get_pipe("polar")
43+
for axis in polar.axes:
44+
docs.sort(key=lambda x: x._.poles[axis.get_key()])
45+
print(f"Top {nn} {axis.neg} reviews:")
46+
print()
47+
for ii in range(nn):
48+
doc = docs[ii]
49+
print(format_review(doc.text))
50+
print()
51+
print()
52+
print(f"Top {nn} {axis.pos} reviews:")
53+
print()
54+
for ii in range(nn):
55+
doc = docs[(ii + 1) * -1]
56+
print(format_review(doc.text))
57+
print()
58+
print("=" * 70)
59+
60+
61+
def main():
62+
nlp = create_polar_pipeline()
63+
check_axes(nlp, 3)
64+
65+
66+
if __name__ == "__main__":
67+
main()
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from spacy import Language
2+
from spacy.tokens import Doc
3+
from spacy.pipeline import Pipe
4+
from spacy.vocab import Vocab
5+
6+
from thinc.types import Floats1d, Floats2d
7+
from thinc.api import Model, CosineDistance, get_ops
8+
9+
from dataclasses import dataclass
10+
11+
Doc.set_extension("poles", default={}, force=True)
12+
13+
14+
@dataclass
15+
class Axis:
16+
"""An invididual semantic axis."""
17+
18+
neg: str
19+
pos: str
20+
vector: Floats1d
21+
22+
def get_key(self, sep="-"):
23+
return f"{self.neg}{sep}{self.pos}"
24+
25+
26+
@Language.factory(
27+
"polar",
28+
requires=["doc.vector"],
29+
default_config={},
30+
default_score_weights={},
31+
)
32+
def make_polar_embeddings(
33+
nlp: Language,
34+
name: str,
35+
):
36+
return PolarEmbeddings(
37+
nlp,
38+
name,
39+
)
40+
41+
42+
class PolarEmbeddings(Pipe):
43+
"""PolarEmbeddings let you turn normal word embeddings into embeddings
44+
oriented along axes of meaning, preserving the overall distance of the
45+
original embeddings while giving dimensions semantic meaning.
46+
"""
47+
48+
def __init__(
49+
self,
50+
nlp: Language,
51+
name: str = "polar",
52+
*,
53+
separator: str = "-",
54+
) -> None:
55+
self.nlp = nlp
56+
self.name = name
57+
self.separator = separator
58+
59+
self.ops = get_ops("numpy")
60+
self.cosine = CosineDistance()
61+
self._matrix = None
62+
self.axes = []
63+
self.cfg = {}
64+
65+
def get_average_neighbors(self, query, nn=150):
66+
"""Given a query vector, return the average of the nearest vecs.
67+
68+
Used to calculate pole vectors."""
69+
# Note that in spaCy pipelines for many languages vectors include case
70+
# variations, so nn should be larger than in the reference paper
71+
seed = self.nlp.vocab[query].vector
72+
vectors = self.nlp.vocab.vectors
73+
qarray = self.ops.asarray2f([seed])
74+
keys, best_rows, scores = vectors.most_similar(qarray, n=nn)
75+
targets = vectors.data[best_rows].squeeze()
76+
77+
return self.ops.xp.mean(targets, axis=0)
78+
79+
def add_axis(self, neg: str, pos: str) -> None:
80+
"""Add a new pole to the pipe. Pass the negative word first."""
81+
82+
notfound = "Anchor word '{}' not found in vocab."
83+
84+
if not self.nlp.vocab.has_vector(neg):
85+
raise KeyError(notfound.format(neg))
86+
if not self.nlp.vocab.has_vector(pos):
87+
raise KeyError(notfound.format(pos))
88+
89+
pv = self.get_average_neighbors(pos)
90+
nv = self.get_average_neighbors(neg)
91+
self.axes.append(Axis(neg, pos, pv - nv))
92+
self._update_axes_matrix()
93+
94+
def _update_axes_matrix(self) -> None:
95+
vecs = [aa.vector for aa in self.axes]
96+
self._matrix = self.ops.asarray2f(vecs).T
97+
98+
def __call__(self, doc: Doc) -> Doc:
99+
docvec = self.ops.xp.expand_dims(doc.vector, axis=1)
100+
docvec = docvec.repeat(len(self.axes), axis=1)
101+
dists = self.cosine.get_similarity(docvec.T, self._matrix.T)
102+
103+
for axis, dist in zip(self.axes, dists):
104+
key = axis.get_key(sep=self.separator)
105+
doc._.poles[key] = float(dist)
106+
return doc

0 commit comments

Comments
 (0)