File size: 11,763 Bytes
7e2fd25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ca240a9
 
edd7257
7e2fd25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5f5452
 
 
 
 
7e2fd25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5f5452
 
7e2fd25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5f5452
 
 
 
 
7e2fd25
f5f5452
 
 
 
 
 
 
 
 
 
7e2fd25
 
 
 
 
 
 
 
 
 
 
 
 
 
649c859
7e2fd25
f5f5452
7e2fd25
f5f5452
 
 
 
 
 
 
 
7e2fd25
 
649c859
 
7e2fd25
649c859
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
edd7257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
"""Three-axis spatial-geometric profile of a single text.

Implements a simplified, single-text reading of Vallego (2026),
"A Spatial-Geometric Framework for Discourse Comparison".

Method
------
1. Each axis is defined by two poles, each a set of exemplar sentences.
   Its raw direction is the difference between the two pole centroids
   (high-pole centroid minus low-pole centroid) in embedding space.
2. The three raw directions overlap (they are not orthogonal). They are
   orthogonalised with symmetric (Loewdin) orthogonalisation, which finds
   the orthonormal set closest to the raw directions and, unlike
   Gram-Schmidt, treats all three axes equally (no order dependence).
3. A text is split into sentences; each sentence and the text centroid
   (mean of sentence embeddings) are projected onto the three orthonormal
   directions. These three numbers are the 3D coordinates.
4. Each coordinate is rescaled so that 0% = the low-pole centroid and
   100% = the high-pole centroid on that axis. Values below 0 or above 100
   lie further out than the typical exemplar of that pole.
"""

from __future__ import annotations

import re
from dataclasses import dataclass

import numpy as np
import pandas as pd

AXES = [
    {"id": "eco", "name": "Ecocentrism", "low": "Anthropocentric", "high": "Ecocentric"},
    {"id": "change", "name": "Societal change", "low": "Conservative", "high": "Transformative"},
    {"id": "outlook", "name": "Outlook", "low": "Optimistic", "high": "Pessimistic"},
]


def split_sentences(text: str, min_words: int = 3) -> list[str]:
    parts = re.split(r"(?<=[.!?;])\s+|\n+", text.strip())
    sents = [p.strip() for p in parts if len(p.strip().split()) >= min_words]
    return sents or ([text.strip()] if text.strip() else [])


def _normalise(x: np.ndarray) -> np.ndarray:
    return x / np.linalg.norm(x, axis=-1, keepdims=True).clip(min=1e-12)


def loewdin(D: np.ndarray) -> np.ndarray:
    """Symmetric orthogonalisation of the rows of D: Q = (D D^T)^(-1/2) D."""
    S = D @ D.T
    vals, vecs = np.linalg.eigh(S)
    S_inv_sqrt = vecs @ np.diag(1.0 / np.sqrt(vals)) @ vecs.T
    return S_inv_sqrt @ D


@dataclass
class Space:
    axes: list[dict]
    Q: np.ndarray            # 3 x d, orthonormal axis directions
    raw_dirs: np.ndarray     # 3 x d, unit raw directions (before orthogonalisation)
    lo: np.ndarray           # 3, projection of each low-pole centroid on its own axis
    hi: np.ndarray           # 3, projection of each high-pole centroid on its own axis
    raw_lo: np.ndarray
    raw_hi: np.ndarray
    pole_points: dict        # (axis_id, pole) -> (sentences, coords n x 3 in %)
    pole_centroids: dict     # (axis_id, pole) -> coords (3,) in %
    overlap: pd.DataFrame    # cosine between raw directions
    topic: np.ndarray = None  # unit vector: centre of all pole exemplars ("the topic")

    def relevance(self, E: np.ndarray) -> np.ndarray:
        """Cosine similarity of each (unit-length) embedding to the topic centre."""
        return E @ self.topic

    def to_percent(self, proj: np.ndarray) -> np.ndarray:
        return 100.0 * (proj - self.lo) / (self.hi - self.lo)

    def coords(self, E: np.ndarray) -> np.ndarray:
        return self.to_percent(E @ self.Q.T)

    def raw_percent(self, E: np.ndarray) -> np.ndarray:
        return 100.0 * (E @ self.raw_dirs.T - self.raw_lo) / (self.raw_hi - self.raw_lo)


def build_space(poles: pd.DataFrame, encode, axes=AXES) -> Space:
    """poles: DataFrame with columns axis, pole ('low'/'high'), text.
    encode: function list[str] -> np.ndarray (n x d)."""
    emb, cents = {}, {}
    for ax in axes:
        for pole in ("low", "high"):
            texts = poles[(poles.axis == ax["id"]) & (poles.pole == pole)].text.tolist()
            if len(texts) < 2:
                raise ValueError(f"Axis '{ax['id']}' pole '{pole}' needs at least 2 exemplars.")
            E = _normalise(np.asarray(encode(texts), dtype=float))
            emb[(ax["id"], pole)] = (texts, E)
            cents[(ax["id"], pole)] = E.mean(axis=0)

    raw = np.stack([cents[(a["id"], "high")] - cents[(a["id"], "low")] for a in axes])
    raw_unit = _normalise(raw)
    Q = loewdin(raw)

    lo = np.array([cents[(a["id"], "low")] @ Q[k] for k, a in enumerate(axes)])
    hi = np.array([cents[(a["id"], "high")] @ Q[k] for k, a in enumerate(axes)])
    raw_lo = np.array([cents[(a["id"], "low")] @ raw_unit[k] for k, a in enumerate(axes)])
    raw_hi = np.array([cents[(a["id"], "high")] @ raw_unit[k] for k, a in enumerate(axes)])

    names = [f"{a['low']}–{a['high']}" for a in axes]
    overlap = pd.DataFrame(raw_unit @ raw_unit.T, index=names, columns=names).round(2)

    topic = _normalise(np.vstack([E for _, E in emb.values()]).mean(axis=0))
    space = Space(axes, Q, raw_unit, lo, hi, raw_lo, raw_hi, {}, {}, overlap, topic)
    for key, (texts, E) in emb.items():
        space.pole_points[key] = (texts, space.coords(E))
        space.pole_centroids[key] = space.coords(cents[key][None, :])[0]
    return space


def reading(pct: float, low: str, high: str) -> str:
    if pct < 10:
        return f"Strongly {low.lower()}"
    if pct < 40:
        return f"Leans {low.lower()}"
    if pct <= 60:
        return "Between the poles"
    if pct <= 90:
        return f"Leans {high.lower()}"
    return f"Strongly {high.lower()}"


def analyse(text: str, space: Space, encode, threshold: float | None = None) -> dict:
    """threshold: if given, only sentences whose similarity to the topic centre is at
    least this value are analysed (relevance filter). If none pass, all are used."""
    all_sents = split_sentences(text)
    if not all_sents:
        raise ValueError("Please enter some text.")
    E_all = _normalise(np.asarray(encode(all_sents), dtype=float))
    rel = space.relevance(E_all)
    keep = np.ones(len(all_sents), dtype=bool) if threshold is None else rel >= threshold
    fallback = bool(threshold is not None and not keep.any())
    if fallback:
        keep[:] = True

    sents = [s for s, k in zip(all_sents, keep) if k]
    E = E_all[keep]
    all_xyz = space.coords(E_all)
    centroid = E.mean(axis=0)
    sent_xyz = space.coords(E)
    text_xyz = space.coords(centroid[None, :])[0]
    raw_pct = space.raw_percent(centroid[None, :])[0]

    rows = []
    for k, ax in enumerate(space.axes):
        rows.append({
            "Axis (0% → 100%)": f"{ax['low']} → {ax['high']}",
            "Position": round(float(text_xyz[k]), 1),
            "Reading": reading(float(text_xyz[k]), ax["low"], ax["high"]),
            "Spread across sentences (±)": round(float(sent_xyz[:, k].std()), 1) if len(sents) > 1 else None,
            "Before orthogonalisation": round(float(raw_pct[k]), 1),
        })
    cloud = cloud_measures(E, sent_xyz, space)
    return {
        "sentences": sents,                 # analysed sentences
        "sent_xyz": sent_xyz,
        "sent_rel": rel[keep],
        "off_sentences": [s for s, k in zip(all_sents, keep) if not k],
        "off_xyz": all_xyz[~keep],
        "off_rel": rel[~keep],
        "n_total": len(all_sents),
        "threshold": threshold,
        "fallback": fallback,
        "unfiltered_xyz": space.coords(E_all.mean(axis=0)[None, :])[0],
        "text_xyz": text_xyz,
        "table": pd.DataFrame(rows),
        "cloud": cloud,
        "cloud_table": cloud_table(cloud, space),
    }


def cloud_measures(E: np.ndarray, sent_xyz: np.ndarray, space: Space) -> dict:
    """Spread (Frobenius norm) and eigenanalysis of the cloud of sentences.

    - frobenius: Frobenius norm of the centred sentence positions (3D, % units),
      divided by sqrt(n) = root-mean-square distance of a sentence from the text centre.
    - captured: Pythagorean decomposition in the full embedding space. The squared
      Frobenius norm of the centred sentence embeddings splits exactly into the part
      lying along the three orthonormal axes and the residual; this is the axes' share.
    - eigen: eigen-decomposition of the 3x3 covariance of the sentence positions.
    """
    n = len(sent_xyz)
    out = {"n": n}
    if n < 2:
        return out
    Xc = sent_xyz - sent_xyz.mean(axis=0)
    out["frobenius"] = float(np.linalg.norm(Xc) / np.sqrt(n))

    Ec = E - E.mean(axis=0)
    total = float(np.sum(Ec ** 2))
    on_axes = float(np.sum((Ec @ space.Q.T) ** 2))
    out["captured"] = on_axes / total if total > 0 else float("nan")

    if n >= 3:
        cov = Xc.T @ Xc / n
        vals, vecs = np.linalg.eigh(cov)
        order = np.argsort(vals)[::-1]
        vals, vecs = np.clip(vals[order], 0, None), vecs[:, order]
        out["eigvals"] = vals
        out["eigvecs"] = vecs                      # columns = principal directions
        out["share_main"] = float(vals[0] / vals.sum()) if vals.sum() > 0 else float("nan")
        v1 = vecs[:, 0]
        out["angles"] = np.degrees(np.arccos(np.clip(np.abs(v1), 0, 1)))  # to each axis
        out["main_axis"] = int(np.argmax(np.abs(v1)))
    return out


def cloud_table(c: dict, space: Space) -> pd.DataFrame:
    rows = []

    def add(measure, value, meaning):
        rows.append({"Measure": measure, "Value": value, "What it means": meaning})

    if c["n"] < 2:
        add("Spread and main direction", "Needs 2+ sentences",
            "These measures describe how the sentences of a text spread out.")
        return pd.DataFrame(rows)

    add("Spread (Frobenius norm)", f"{c['frobenius']:.1f} points",
        "Average distance of a sentence from the text's centre across the three axes. "
        "Lower means a more consistent text.")
    add("Variation captured by the axes", f"{100 * c['captured']:.0f}%",
        "Share of the sentences' total variation in meaning that lies along these three axes. "
        "The rest concerns other things, such as topic or style.")
    if "eigvals" in c:
        ax = space.axes[c["main_axis"]]
        add("Main direction of variation (eigenanalysis)",
            f"Mostly {ax['name']} ({c['angles'][c['main_axis']]:.0f}° from it)",
            "The direction along which the sentences differ most, and the axis it is closest to.")
        add("Variation along the main direction", f"{100 * c['share_main']:.0f}%",
            "Near 33%: sentences vary evenly in all directions. "
            "Near 100%: they vary along a single line.")
    else:
        add("Main direction of variation (eigenanalysis)", "Needs 3+ sentences", "")
    return pd.DataFrame(rows)


def voronoi_zones(points: np.ndarray, box: tuple) -> list:
    """Zones of a 2D map: for each point, the polygon of the plane that is closer to it
    than to any other point (a Voronoi cell), clipped to box = (xmin, xmax, ymin, ymax).
    Pure numpy (Sutherland-Hodgman clipping by perpendicular bisectors)."""
    xmin, xmax, ymin, ymax = box
    square = np.array([[xmin, ymin], [xmax, ymin], [xmax, ymax], [xmin, ymax]], dtype=float)
    zones = []
    for i, p in enumerate(points):
        poly = square
        for j, q in enumerate(points):
            if i == j or np.allclose(p, q):
                continue
            n, mid = q - p, (p + q) / 2.0          # keep the side of the bisector nearer to p
            inside = lambda v: (v - mid) @ n <= 1e-9
            out = []
            for k in range(len(poly)):
                a, b = poly[k], poly[(k + 1) % len(poly)]
                ina, inb = inside(a), inside(b)
                if ina:
                    out.append(a)
                if ina != inb:
                    t = ((mid - a) @ n) / ((b - a) @ n)
                    out.append(a + t * (b - a))
            poly = np.array(out) if out else np.empty((0, 2))
            if len(poly) == 0:
                break
        zones.append(poly)
    return zones