Skip to content

SeeTrees¤

The seetrees module provides stylometric analysis and visualization tools for document-term-style data. It is especially useful for comparing document profiles, computing distance matrices, visualizing document relationships, and exploring feature importance across clusters.

SeeTrees is adapted from the R 'see' package by Artjoms Šeļa.

The SeeTrees Class¤

SeeTrees pydantic-model ¤

Bases: BaseModel

SeeTrees class for stylometric analysis and visualization.

Config:

  • arbitrary_types_allowed: True
  • validate_assignment: True

Fields:

Source code in lexos/cluster/seetrees/__init__.py
 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
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
class SeeTrees(BaseModel):
    """SeeTrees class for stylometric analysis and visualization."""

    distance_table: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Optional distance matrix."
    )
    dtm: DTM | None = Field(
        default=None, description="Optional Lexos DTM to initialize frequencies from."
    )
    features: list[str] = Field(
        default_factory=list, description="Optional list of feature names."
    )
    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Optional frequency table."
    )
    labels: list[str] = Field(
        default_factory=list,
        description="Document labels derived from the distance matrix or frequency table.",
    )
    stylo_res: dict | None = Field(
        default=None,
        description="Optional dictionary containing stylo output keys such as `frequencies`, `distance_table`, and `features`.",
    )
    fig: plt.Figure | None = Field(
        default=None, description="Matplotlib Figure object for plotting."
    )

    model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)

    def __init__(self, **data):
        """Initialize a SeeTrees instance."""
        super().__init__(**data)
        if self.dtm is not None:
            self._init_from_dtm()
        elif self.stylo_res is not None:
            self._init_from_stylo_res()
        else:
            self._init_from_raw()

        self._ensure_dense_frequencies()
        self._init_labels()

    def _apply_figure_layout(
        self,
        fig,
        left: float = 0.06,
        right: float = 0.96,
        top: float = 0.94,
        bottom: float = 0.12,
    ):
        """Apply a compact layout to a Matplotlib Figure."""
        fig.subplots_adjust(left=left, right=right, top=top, bottom=bottom)
        self._disable_canvas_bbox_inches(fig)

    def _disable_canvas_bbox_inches(self, fig):
        """Disable bbox_inches overrides on the figure's canvas print method."""
        canvas = getattr(fig, "canvas", None)
        if canvas is None or not hasattr(canvas, "print_figure"):
            return

        original_print_figure = canvas.print_figure

        def _print_figure_no_bbox_inches(*args, **kwargs):
            kwargs.pop("bbox_inches", None)
            return original_print_figure(*args, **kwargs)

        canvas.print_figure = _print_figure_no_bbox_inches

    def _init_from_dtm(self):
        """Initialize frequencies from a Lexos DTM."""
        self.frequencies = self.dtm.to_df(transpose=True)
        self.distance_table = (
            pd.DataFrame(self.distance_table)
            if self.distance_table is not None
            else pd.DataFrame()
        )
        self.features = list(self.features) if self.features is not None else []

    def _init_from_stylo_res(self):
        """Initialize frequencies, distance table, and features from a stylo result dictionary."""
        self.frequencies = pd.DataFrame(self.stylo_res.get("frequencies", {}))
        self.distance_table = pd.DataFrame(self.stylo_res.get("distance_table", {}))
        self.features = list(self.stylo_res.get("features", []))

    def _init_from_raw(self):
        self.frequencies = (
            pd.DataFrame(self.frequencies)
            if self.frequencies is not None
            else pd.DataFrame()
        )
        self.distance_table = (
            pd.DataFrame(self.distance_table)
            if self.distance_table is not None
            else pd.DataFrame()
        )
        self.features = list(self.features) if self.features is not None else []

    def _ensure_dense_frequencies(self):
        """Ensure frequencies are stored as a dense float DataFrame."""
        if hasattr(self.frequencies, "sparse"):
            self.frequencies = self.frequencies.sparse.to_dense()
        self.frequencies = self.frequencies.astype(float)

    def _init_labels(self):
        if not self.distance_table.empty:
            self.labels = list(self.distance_table.index)
        else:
            self.labels = (
                list(self.frequencies.index) if not self.frequencies.empty else []
            )

    def get_difference_plot(
        self,
        source_text: str,
        target_text: str,
        top_diff: int = 10,
        max_rank: int = 100,
        title: str | None = None,
        base_color: str = "gray",
        highlight_color: str = "red",
    ) -> DifferencePlot:
        """Get a difference plot for two documents.

        Args:
            source_text (str): The reference text label.
            target_text (str): The text label to compare.
            top_diff (int): Number of top differing features to label.
            max_rank (int): Maximum feature frequency rank to display.

        Returns:
            DifferencePlot: An object for visualizing the difference in z-scores.

        Raises:
            ValueError: If the frequency table is empty or either label is missing.
        """
        if self.frequencies.empty:
            raise ValueError("Frequency data is required for get_difference_plot.")
        if source_text not in self.frequencies.index:
            raise ValueError(f"Source text '{source_text}' not found in corpus.")
        if target_text not in self.frequencies.index:
            raise ValueError(f"Target text '{target_text}' not found in corpus.")

        return DifferencePlot(
            frequencies=self.frequencies,
            source_text=source_text,
            target_text=target_text,
            top_diff=top_diff,
            max_rank=max_rank,
            title=title,
            base_color=base_color,
            highlight_color=highlight_color,
        )

    def get_overlay_plot(
        self,
        source_text: str,
        target_text: str,
        top_diff: int = 10,
        max_rank: int = 100,
        title: str | None = None,
        source_color: str = "#ff9999",
        target_color: str = "#99c2ff",
    ) -> OverlayPlot:
        """Compare two documents using overlay plotting.

        Args:
            source_text (str): The reference text label.
            target_text (str): The text label to compare.
            top_diff (int): Number of top differing features to label.
            max_rank (int): Maximum feature frequency rank to display.

        Returns:
            OverlayPlot: An OverlayPlot object for the specified documents.

        Raises:
            ValueError: If the frequency table is empty or either label is missing.
        """
        if self.frequencies.empty:
            raise ValueError("Frequency data is required for get_overlay_plot.")
        if source_text not in self.frequencies.index:
            raise ValueError(f"Source text '{source_text}' not found in corpus.")
        if target_text not in self.frequencies.index:
            raise ValueError(f"Target text '{target_text}' not found in corpus.")

        return OverlayPlot(
            frequencies=self.frequencies,
            source_text=source_text,
            target_text=target_text,
            top_diff=top_diff,
            max_rank=max_rank,
            title=title,
            source_color=source_color,
            target_color=target_color,
        )

    def compute_distances(self, metric: str = "delta") -> pd.DataFrame:
        """Compute a stylometric distance matrix from frequency data.

        Supports multiple stylometric metrics including Burrows' Delta,
        Eder's Delta, and cosine variants.

        Args:
            metric (str): Distance metric to compute. Valid values are:
                `'delta'`, `'eder_delta'`, `'cosine_delta'`, `'manhattan'`,
                and `'cosine'`.

        Returns:
            pd.DataFrame: Pairwise distance matrix indexed by the original labels.

        Raises:
            ValueError: If the frequency table is empty or the metric is unknown.
        """
        if self.frequencies.empty:
            raise ValueError("Frequency table is required to compute distance metrics.")

        # Features must be ordered from most frequent to least frequent for Eder's Delta
        # Calculate mean frequency across corpus to determine exact rank
        mean_freqs = self.frequencies.mean().sort_values(ascending=False)
        ordered_freqs = self.frequencies[mean_freqs.index]

        # Calculate standard Z-scores
        z_scores = (ordered_freqs - ordered_freqs.mean()) / ordered_freqs.std()
        z_scores = z_scores.fillna(0)
        n_features = z_scores.shape[1]

        if metric.lower() == "delta":
            distances = pdist(z_scores.to_numpy(), metric="cityblock") / n_features

        elif metric.lower() == "eder_delta":
            # Assign 1-based ranks for the features
            ranks = np.arange(1, n_features + 1)
            # Apply Eder's descending linear weight formula
            eder_weights = -(ranks / n_features) + 1 + (1 / n_features)

            # Multiply scaled Z-scores by the weights
            weighted_z = z_scores.to_numpy() * eder_weights
            distances = pdist(weighted_z, metric="cityblock")

        elif metric.lower() == "cosine_delta":
            distances = pdist(z_scores.to_numpy(), metric="cosine")
        elif metric.lower() == "manhattan":
            distances = pdist(ordered_freqs.to_numpy(), metric="cityblock")
        elif metric.lower() == "cosine":
            distances = pdist(ordered_freqs.to_numpy(), metric="cosine")
        else:
            raise ValueError(
                f"Unknown metric '{metric}'. Choose from: 'delta', 'eder_delta', 'cosine_delta', 'manhattan', 'cosine'."
            )

        # Update the module's distance table matrix
        self.distance_table = pd.DataFrame(
            squareform(distances),
            index=ordered_freqs.index,
            columns=ordered_freqs.index,
        )
        self.labels = list(self.distance_table.index)
        return self.distance_table

    def get_density_plot(
        self,
        group: bool = True,
        author: str | None = None,
        pattern: str = r"^.*?(?=[_\s-]|\d)",
        title: str | None = None,
        palette: dict[str, str] | None = None,
        color: str = "#cccccc",
        left: float = 0.14,
        right: float = 0.95,
        top: float = 0.92,
        bottom: float = 0.15,
    ) -> DensityPlot:
        """Return a DensityPlot object for viewing distances.

        Args:
            group (bool): Whether to group distances by the same author/class.
            author (str | None): Specific author/class to highlight in the plot.
            pattern (str): Regex pattern to extract author classes from labels.
            title (str | None): Optional title for the density plot.
            palette (dict[str, str] | None): Optional color palette for grouped density curves.
            color (str): Fill color for ungrouped density plots.
            left (float): Left margin for the figure layout.
            right (float): Right margin for the figure layout.
            top (float): Top margin for the figure layout.
            bottom (float): Bottom margin for the figure layout.

        Returns:
            DensityPlot: Configured DensityPlot object.
        """
        if self.distance_table is None or self.distance_table.empty:
            raise ValueError(
                "Distance table is required to create a DensityPlot. Run compute_distances() first."
            )
        plotter = DensityPlot(
            distance_table=self.distance_table,
            labels=self.labels,
            frequencies=self.frequencies,
            author=author,
            group=group,
            pattern=pattern,
            title=title,
            palette=palette,
            color=color,
            left=left,
            right=right,
            top=top,
            bottom=bottom,
        )
        return plotter

    def get_mds_plot(
        self,
        group: bool = True,
        author: str | None = None,
        pattern: str = r"^.*?(?=[_\s-]|\d)",
        title: str | None = None,
        left: float = 0.12,
        right: float = 0.96,
        top: float = 0.94,
        bottom: float = 0.12,
    ) -> MDS:
        """Return an MDS object for viewing distances.

        Args:
            group (bool): Whether to group distances by the same author/class.
            author (str | None): Specific author/class to highlight in the plot.
            pattern (str): Regex pattern to extract author classes from labels.
            title (str | None): Optional title for the MDS plot.
            left (float): Left margin for the figure layout.
            right (float): Right margin for the figure layout.
            top (float): Top margin for the figure layout.
            bottom (float): Bottom margin for the figure layout.

        Returns:
            MDS: Configured MDS object.
        """
        if self.distance_table is None or self.distance_table.empty:
            raise ValueError(
                "Distance table is required to create an MDS plot. Run compute_distances() first."
            )
        plotter = MDS(
            distance_table=self.distance_table,
            labels=self.labels,
            frequencies=self.frequencies,
            author=author,
            group=group,
            pattern=pattern,
            title=title,
            left=left,
            right=right,
            top=top,
            bottom=bottom,
        )
        return plotter

    def get_pca_plot(
        self,
        author: str | None = None,
        pattern: str = r"^.*?(?=[_\s-]|\d)",
        title: str | None = None,
        left: float = 0.12,
        right: float = 0.96,
        top: float = 0.94,
        bottom: float = 0.12,
    ) -> PCA:
        """Return a PCA object for viewing distances."""
        plotter = PCA(
            distance_table=self.distance_table,
            labels=self.labels,
            frequencies=self.frequencies,
            author=author,
            pattern=pattern,
            title=title,
            left=left,
            right=right,
            top=top,
            bottom=bottom,
        )
        return plotter

    def get_feature_summary(
        self,
        target_text: str,
        top: int = 20,
    ):
        """Return the most distinctive features for a target text."""
        if self.frequencies.empty:
            raise ValueError("Frequency data is required for get_feature_summary.")
        if target_text not in self.frequencies.index:
            raise ValueError(f"Target text '{target_text}' not found in corpus.")

        summary = FeatureSummary(
            frequencies=self.frequencies,
            target_text=target_text,
            top=top,
        )

        return summary.to_dataframe()

    def get_tree(
        self,
        k: int = 2,
        method: str = "ward",
        title: str | None = None,
        top_n_words: int = 10,
        orientation: str = "right",
        label_buffer: float = 0.0,
        outline_y_pad: float = 0.3,
        outline_axis_y_pad: float = 0.1,
        outline_tip_pad_ratio: float = 0.002,
        outline_root_pad_ratio: float = 0.1,
    ) -> Tree:
        """Return a Tree object for further customization or saving.

        Args:
            k (int): Number of clusters to display in the dendrogram.
            method (str): Linkage method for hierarchical clustering.
            title (str | None): Optional title for the dendrogram.
            top_n_words (int): Number of top words to display for each cluster.
            orientation (str): Dendrogram orientation. One of 'left', 'right', 'top', or 'bottom'.
            label_buffer (float): Extra subplot margin reserved for leaf labels on the active label side.
            outline_y_pad (float): Vertical padding for cluster outlines.
            outline_axis_y_pad (float): Additional vertical padding for the axis.
            outline_tip_pad_ratio (float): Horizontal padding ratio for dendrogram tips.
            outline_root_pad_ratio (float): Horizontal padding ratio for dendrogram root.
        """
        tree = Tree(
            labels=self.labels,
            distance_table=self.distance_table,
            frequencies=self.frequencies,
            title=title,
        )
        tree.plot_tree(
            k=k,
            method=method,
            top_n_words=top_n_words,
            orientation=orientation,
            label_buffer=label_buffer,
            outline_y_pad=outline_y_pad,
            outline_axis_y_pad=outline_axis_y_pad,
            outline_tip_pad_ratio=outline_tip_pad_ratio,
            outline_root_pad_ratio=outline_root_pad_ratio,
        )
        return tree

    def get_feature_score_plot(
        self,
        target_text: str,
        top: int = 20,
        title: str | None = None,
        positive_color: str = "#f6c1cc",
        negative_color: str = "#b9dff1",
        guide_color: str = "#c9ced6",
        zero_line_color: str = "red",
        height: int = 600,
        width: int = 800,
    ) -> DistinctiveFeaturePlot:
        """Return a DistinctiveFeaturePlot object for top feature score visualization.

        Args:
            target_text (str): The text to analyze for distinctive features.
            top (int): Number of top features to display.
            title (str | None): Optional chart title.
            positive_color (str): Bar color for positive z-scores.
            negative_color (str): Bar color for negative z-scores.
            guide_color (str): Dotted guide-line color for non-zero SD lines.
            zero_line_color (str): Dotted guide-line color for the zero SD line.
            height (int): Figure height in pixels.
            width (int): Figure width in pixels.

        Returns:
            DistinctiveFeaturePlot: Configured Plotly plot object.

        Raises:
            ValueError: If the frequency table is empty or if the target text is not found.
        """
        if self.frequencies.empty:
            raise ValueError("Frequency data is required for get_feature_score_plot.")
        if target_text not in self.frequencies.index:
            raise ValueError(f"Target text '{target_text}' not found in corpus.")

        return DistinctiveFeaturePlot(
            frequencies=self.frequencies,
            target_text=target_text,
            top=top,
            title=title,
            positive_color=positive_color,
            negative_color=negative_color,
            guide_color=guide_color,
            zero_line_color=zero_line_color,
            width=width,
            height=height,
        )

distance_table: pd.DataFrame pydantic-field ¤

Optional distance matrix.

dtm: DTM | None = None pydantic-field ¤

Optional Lexos DTM to initialize frequencies from.

features: list[str] pydantic-field ¤

Optional list of feature names.

frequencies: pd.DataFrame pydantic-field ¤

Optional frequency table.

labels: list[str] pydantic-field ¤

Document labels derived from the distance matrix or frequency table.

stylo_res: dict | None = None pydantic-field ¤

Optional dictionary containing stylo output keys such as frequencies, distance_table, and features.

fig: plt.Figure | None = None pydantic-field ¤

Matplotlib Figure object for plotting.

__init__(**data) ¤

Initialize a SeeTrees instance.

Source code in lexos/cluster/seetrees/__init__.py
def __init__(self, **data):
    """Initialize a SeeTrees instance."""
    super().__init__(**data)
    if self.dtm is not None:
        self._init_from_dtm()
    elif self.stylo_res is not None:
        self._init_from_stylo_res()
    else:
        self._init_from_raw()

    self._ensure_dense_frequencies()
    self._init_labels()

get_difference_plot(source_text: str, target_text: str, top_diff: int = 10, max_rank: int = 100, title: str | None = None, base_color: str = 'gray', highlight_color: str = 'red') -> DifferencePlot ¤

Get a difference plot for two documents.

Parameters:

Name Type Description Default
source_text str

The reference text label.

required
target_text str

The text label to compare.

required
top_diff int

Number of top differing features to label.

10
max_rank int

Maximum feature frequency rank to display.

100

Returns:

Name Type Description
DifferencePlot DifferencePlot

An object for visualizing the difference in z-scores.

Raises:

Type Description
ValueError

If the frequency table is empty or either label is missing.

Source code in lexos/cluster/seetrees/__init__.py
def get_difference_plot(
    self,
    source_text: str,
    target_text: str,
    top_diff: int = 10,
    max_rank: int = 100,
    title: str | None = None,
    base_color: str = "gray",
    highlight_color: str = "red",
) -> DifferencePlot:
    """Get a difference plot for two documents.

    Args:
        source_text (str): The reference text label.
        target_text (str): The text label to compare.
        top_diff (int): Number of top differing features to label.
        max_rank (int): Maximum feature frequency rank to display.

    Returns:
        DifferencePlot: An object for visualizing the difference in z-scores.

    Raises:
        ValueError: If the frequency table is empty or either label is missing.
    """
    if self.frequencies.empty:
        raise ValueError("Frequency data is required for get_difference_plot.")
    if source_text not in self.frequencies.index:
        raise ValueError(f"Source text '{source_text}' not found in corpus.")
    if target_text not in self.frequencies.index:
        raise ValueError(f"Target text '{target_text}' not found in corpus.")

    return DifferencePlot(
        frequencies=self.frequencies,
        source_text=source_text,
        target_text=target_text,
        top_diff=top_diff,
        max_rank=max_rank,
        title=title,
        base_color=base_color,
        highlight_color=highlight_color,
    )

get_overlay_plot(source_text: str, target_text: str, top_diff: int = 10, max_rank: int = 100, title: str | None = None, source_color: str = '#ff9999', target_color: str = '#99c2ff') -> OverlayPlot ¤

Compare two documents using overlay plotting.

Parameters:

Name Type Description Default
source_text str

The reference text label.

required
target_text str

The text label to compare.

required
top_diff int

Number of top differing features to label.

10
max_rank int

Maximum feature frequency rank to display.

100

Returns:

Name Type Description
OverlayPlot OverlayPlot

An OverlayPlot object for the specified documents.

Raises:

Type Description
ValueError

If the frequency table is empty or either label is missing.

Source code in lexos/cluster/seetrees/__init__.py
def get_overlay_plot(
    self,
    source_text: str,
    target_text: str,
    top_diff: int = 10,
    max_rank: int = 100,
    title: str | None = None,
    source_color: str = "#ff9999",
    target_color: str = "#99c2ff",
) -> OverlayPlot:
    """Compare two documents using overlay plotting.

    Args:
        source_text (str): The reference text label.
        target_text (str): The text label to compare.
        top_diff (int): Number of top differing features to label.
        max_rank (int): Maximum feature frequency rank to display.

    Returns:
        OverlayPlot: An OverlayPlot object for the specified documents.

    Raises:
        ValueError: If the frequency table is empty or either label is missing.
    """
    if self.frequencies.empty:
        raise ValueError("Frequency data is required for get_overlay_plot.")
    if source_text not in self.frequencies.index:
        raise ValueError(f"Source text '{source_text}' not found in corpus.")
    if target_text not in self.frequencies.index:
        raise ValueError(f"Target text '{target_text}' not found in corpus.")

    return OverlayPlot(
        frequencies=self.frequencies,
        source_text=source_text,
        target_text=target_text,
        top_diff=top_diff,
        max_rank=max_rank,
        title=title,
        source_color=source_color,
        target_color=target_color,
    )

compute_distances(metric: str = 'delta') -> pd.DataFrame ¤

Compute a stylometric distance matrix from frequency data.

Supports multiple stylometric metrics including Burrows' Delta, Eder's Delta, and cosine variants.

Parameters:

Name Type Description Default
metric str

Distance metric to compute. Valid values are: 'delta', 'eder_delta', 'cosine_delta', 'manhattan', and 'cosine'.

'delta'

Returns:

Type Description
DataFrame

pd.DataFrame: Pairwise distance matrix indexed by the original labels.

Raises:

Type Description
ValueError

If the frequency table is empty or the metric is unknown.

Source code in lexos/cluster/seetrees/__init__.py
def compute_distances(self, metric: str = "delta") -> pd.DataFrame:
    """Compute a stylometric distance matrix from frequency data.

    Supports multiple stylometric metrics including Burrows' Delta,
    Eder's Delta, and cosine variants.

    Args:
        metric (str): Distance metric to compute. Valid values are:
            `'delta'`, `'eder_delta'`, `'cosine_delta'`, `'manhattan'`,
            and `'cosine'`.

    Returns:
        pd.DataFrame: Pairwise distance matrix indexed by the original labels.

    Raises:
        ValueError: If the frequency table is empty or the metric is unknown.
    """
    if self.frequencies.empty:
        raise ValueError("Frequency table is required to compute distance metrics.")

    # Features must be ordered from most frequent to least frequent for Eder's Delta
    # Calculate mean frequency across corpus to determine exact rank
    mean_freqs = self.frequencies.mean().sort_values(ascending=False)
    ordered_freqs = self.frequencies[mean_freqs.index]

    # Calculate standard Z-scores
    z_scores = (ordered_freqs - ordered_freqs.mean()) / ordered_freqs.std()
    z_scores = z_scores.fillna(0)
    n_features = z_scores.shape[1]

    if metric.lower() == "delta":
        distances = pdist(z_scores.to_numpy(), metric="cityblock") / n_features

    elif metric.lower() == "eder_delta":
        # Assign 1-based ranks for the features
        ranks = np.arange(1, n_features + 1)
        # Apply Eder's descending linear weight formula
        eder_weights = -(ranks / n_features) + 1 + (1 / n_features)

        # Multiply scaled Z-scores by the weights
        weighted_z = z_scores.to_numpy() * eder_weights
        distances = pdist(weighted_z, metric="cityblock")

    elif metric.lower() == "cosine_delta":
        distances = pdist(z_scores.to_numpy(), metric="cosine")
    elif metric.lower() == "manhattan":
        distances = pdist(ordered_freqs.to_numpy(), metric="cityblock")
    elif metric.lower() == "cosine":
        distances = pdist(ordered_freqs.to_numpy(), metric="cosine")
    else:
        raise ValueError(
            f"Unknown metric '{metric}'. Choose from: 'delta', 'eder_delta', 'cosine_delta', 'manhattan', 'cosine'."
        )

    # Update the module's distance table matrix
    self.distance_table = pd.DataFrame(
        squareform(distances),
        index=ordered_freqs.index,
        columns=ordered_freqs.index,
    )
    self.labels = list(self.distance_table.index)
    return self.distance_table

get_density_plot(group: bool = True, author: str | None = None, pattern: str = '^.*?(?=[_\\s-]|\\d)', title: str | None = None, palette: dict[str, str] | None = None, color: str = '#cccccc', left: float = 0.14, right: float = 0.95, top: float = 0.92, bottom: float = 0.15) -> DensityPlot ¤

Return a DensityPlot object for viewing distances.

Parameters:

Name Type Description Default
group bool

Whether to group distances by the same author/class.

True
author str | None

Specific author/class to highlight in the plot.

None
pattern str

Regex pattern to extract author classes from labels.

'^.*?(?=[_\\s-]|\\d)'
title str | None

Optional title for the density plot.

None
palette dict[str, str] | None

Optional color palette for grouped density curves.

None
color str

Fill color for ungrouped density plots.

'#cccccc'
left float

Left margin for the figure layout.

0.14
right float

Right margin for the figure layout.

0.95
top float

Top margin for the figure layout.

0.92
bottom float

Bottom margin for the figure layout.

0.15

Returns:

Name Type Description
DensityPlot DensityPlot

Configured DensityPlot object.

Source code in lexos/cluster/seetrees/__init__.py
def get_density_plot(
    self,
    group: bool = True,
    author: str | None = None,
    pattern: str = r"^.*?(?=[_\s-]|\d)",
    title: str | None = None,
    palette: dict[str, str] | None = None,
    color: str = "#cccccc",
    left: float = 0.14,
    right: float = 0.95,
    top: float = 0.92,
    bottom: float = 0.15,
) -> DensityPlot:
    """Return a DensityPlot object for viewing distances.

    Args:
        group (bool): Whether to group distances by the same author/class.
        author (str | None): Specific author/class to highlight in the plot.
        pattern (str): Regex pattern to extract author classes from labels.
        title (str | None): Optional title for the density plot.
        palette (dict[str, str] | None): Optional color palette for grouped density curves.
        color (str): Fill color for ungrouped density plots.
        left (float): Left margin for the figure layout.
        right (float): Right margin for the figure layout.
        top (float): Top margin for the figure layout.
        bottom (float): Bottom margin for the figure layout.

    Returns:
        DensityPlot: Configured DensityPlot object.
    """
    if self.distance_table is None or self.distance_table.empty:
        raise ValueError(
            "Distance table is required to create a DensityPlot. Run compute_distances() first."
        )
    plotter = DensityPlot(
        distance_table=self.distance_table,
        labels=self.labels,
        frequencies=self.frequencies,
        author=author,
        group=group,
        pattern=pattern,
        title=title,
        palette=palette,
        color=color,
        left=left,
        right=right,
        top=top,
        bottom=bottom,
    )
    return plotter

get_mds_plot(group: bool = True, author: str | None = None, pattern: str = '^.*?(?=[_\\s-]|\\d)', title: str | None = None, left: float = 0.12, right: float = 0.96, top: float = 0.94, bottom: float = 0.12) -> MDS ¤

Return an MDS object for viewing distances.

Parameters:

Name Type Description Default
group bool

Whether to group distances by the same author/class.

True
author str | None

Specific author/class to highlight in the plot.

None
pattern str

Regex pattern to extract author classes from labels.

'^.*?(?=[_\\s-]|\\d)'
title str | None

Optional title for the MDS plot.

None
left float

Left margin for the figure layout.

0.12
right float

Right margin for the figure layout.

0.96
top float

Top margin for the figure layout.

0.94
bottom float

Bottom margin for the figure layout.

0.12

Returns:

Name Type Description
MDS MDS

Configured MDS object.

Source code in lexos/cluster/seetrees/__init__.py
def get_mds_plot(
    self,
    group: bool = True,
    author: str | None = None,
    pattern: str = r"^.*?(?=[_\s-]|\d)",
    title: str | None = None,
    left: float = 0.12,
    right: float = 0.96,
    top: float = 0.94,
    bottom: float = 0.12,
) -> MDS:
    """Return an MDS object for viewing distances.

    Args:
        group (bool): Whether to group distances by the same author/class.
        author (str | None): Specific author/class to highlight in the plot.
        pattern (str): Regex pattern to extract author classes from labels.
        title (str | None): Optional title for the MDS plot.
        left (float): Left margin for the figure layout.
        right (float): Right margin for the figure layout.
        top (float): Top margin for the figure layout.
        bottom (float): Bottom margin for the figure layout.

    Returns:
        MDS: Configured MDS object.
    """
    if self.distance_table is None or self.distance_table.empty:
        raise ValueError(
            "Distance table is required to create an MDS plot. Run compute_distances() first."
        )
    plotter = MDS(
        distance_table=self.distance_table,
        labels=self.labels,
        frequencies=self.frequencies,
        author=author,
        group=group,
        pattern=pattern,
        title=title,
        left=left,
        right=right,
        top=top,
        bottom=bottom,
    )
    return plotter

get_pca_plot(author: str | None = None, pattern: str = '^.*?(?=[_\\s-]|\\d)', title: str | None = None, left: float = 0.12, right: float = 0.96, top: float = 0.94, bottom: float = 0.12) -> PCA ¤

Return a PCA object for viewing distances.

Source code in lexos/cluster/seetrees/__init__.py
def get_pca_plot(
    self,
    author: str | None = None,
    pattern: str = r"^.*?(?=[_\s-]|\d)",
    title: str | None = None,
    left: float = 0.12,
    right: float = 0.96,
    top: float = 0.94,
    bottom: float = 0.12,
) -> PCA:
    """Return a PCA object for viewing distances."""
    plotter = PCA(
        distance_table=self.distance_table,
        labels=self.labels,
        frequencies=self.frequencies,
        author=author,
        pattern=pattern,
        title=title,
        left=left,
        right=right,
        top=top,
        bottom=bottom,
    )
    return plotter

get_feature_summary(target_text: str, top: int = 20) ¤

Return the most distinctive features for a target text.

Source code in lexos/cluster/seetrees/__init__.py
def get_feature_summary(
    self,
    target_text: str,
    top: int = 20,
):
    """Return the most distinctive features for a target text."""
    if self.frequencies.empty:
        raise ValueError("Frequency data is required for get_feature_summary.")
    if target_text not in self.frequencies.index:
        raise ValueError(f"Target text '{target_text}' not found in corpus.")

    summary = FeatureSummary(
        frequencies=self.frequencies,
        target_text=target_text,
        top=top,
    )

    return summary.to_dataframe()

get_tree(k: int = 2, method: str = 'ward', title: str | None = None, top_n_words: int = 10, orientation: str = 'right', label_buffer: float = 0.0, outline_y_pad: float = 0.3, outline_axis_y_pad: float = 0.1, outline_tip_pad_ratio: float = 0.002, outline_root_pad_ratio: float = 0.1) -> Tree ¤

Return a Tree object for further customization or saving.

Parameters:

Name Type Description Default
k int

Number of clusters to display in the dendrogram.

2
method str

Linkage method for hierarchical clustering.

'ward'
title str | None

Optional title for the dendrogram.

None
top_n_words int

Number of top words to display for each cluster.

10
orientation str

Dendrogram orientation. One of 'left', 'right', 'top', or 'bottom'.

'right'
label_buffer float

Extra subplot margin reserved for leaf labels on the active label side.

0.0
outline_y_pad float

Vertical padding for cluster outlines.

0.3
outline_axis_y_pad float

Additional vertical padding for the axis.

0.1
outline_tip_pad_ratio float

Horizontal padding ratio for dendrogram tips.

0.002
outline_root_pad_ratio float

Horizontal padding ratio for dendrogram root.

0.1
Source code in lexos/cluster/seetrees/__init__.py
def get_tree(
    self,
    k: int = 2,
    method: str = "ward",
    title: str | None = None,
    top_n_words: int = 10,
    orientation: str = "right",
    label_buffer: float = 0.0,
    outline_y_pad: float = 0.3,
    outline_axis_y_pad: float = 0.1,
    outline_tip_pad_ratio: float = 0.002,
    outline_root_pad_ratio: float = 0.1,
) -> Tree:
    """Return a Tree object for further customization or saving.

    Args:
        k (int): Number of clusters to display in the dendrogram.
        method (str): Linkage method for hierarchical clustering.
        title (str | None): Optional title for the dendrogram.
        top_n_words (int): Number of top words to display for each cluster.
        orientation (str): Dendrogram orientation. One of 'left', 'right', 'top', or 'bottom'.
        label_buffer (float): Extra subplot margin reserved for leaf labels on the active label side.
        outline_y_pad (float): Vertical padding for cluster outlines.
        outline_axis_y_pad (float): Additional vertical padding for the axis.
        outline_tip_pad_ratio (float): Horizontal padding ratio for dendrogram tips.
        outline_root_pad_ratio (float): Horizontal padding ratio for dendrogram root.
    """
    tree = Tree(
        labels=self.labels,
        distance_table=self.distance_table,
        frequencies=self.frequencies,
        title=title,
    )
    tree.plot_tree(
        k=k,
        method=method,
        top_n_words=top_n_words,
        orientation=orientation,
        label_buffer=label_buffer,
        outline_y_pad=outline_y_pad,
        outline_axis_y_pad=outline_axis_y_pad,
        outline_tip_pad_ratio=outline_tip_pad_ratio,
        outline_root_pad_ratio=outline_root_pad_ratio,
    )
    return tree

get_feature_score_plot(target_text: str, top: int = 20, title: str | None = None, positive_color: str = '#f6c1cc', negative_color: str = '#b9dff1', guide_color: str = '#c9ced6', zero_line_color: str = 'red', height: int = 600, width: int = 800) -> DistinctiveFeaturePlot ¤

Return a DistinctiveFeaturePlot object for top feature score visualization.

Parameters:

Name Type Description Default
target_text str

The text to analyze for distinctive features.

required
top int

Number of top features to display.

20
title str | None

Optional chart title.

None
positive_color str

Bar color for positive z-scores.

'#f6c1cc'
negative_color str

Bar color for negative z-scores.

'#b9dff1'
guide_color str

Dotted guide-line color for non-zero SD lines.

'#c9ced6'
zero_line_color str

Dotted guide-line color for the zero SD line.

'red'
height int

Figure height in pixels.

600
width int

Figure width in pixels.

800

Returns:

Name Type Description
DistinctiveFeaturePlot DistinctiveFeaturePlot

Configured Plotly plot object.

Raises:

Type Description
ValueError

If the frequency table is empty or if the target text is not found.

Source code in lexos/cluster/seetrees/__init__.py
def get_feature_score_plot(
    self,
    target_text: str,
    top: int = 20,
    title: str | None = None,
    positive_color: str = "#f6c1cc",
    negative_color: str = "#b9dff1",
    guide_color: str = "#c9ced6",
    zero_line_color: str = "red",
    height: int = 600,
    width: int = 800,
) -> DistinctiveFeaturePlot:
    """Return a DistinctiveFeaturePlot object for top feature score visualization.

    Args:
        target_text (str): The text to analyze for distinctive features.
        top (int): Number of top features to display.
        title (str | None): Optional chart title.
        positive_color (str): Bar color for positive z-scores.
        negative_color (str): Bar color for negative z-scores.
        guide_color (str): Dotted guide-line color for non-zero SD lines.
        zero_line_color (str): Dotted guide-line color for the zero SD line.
        height (int): Figure height in pixels.
        width (int): Figure width in pixels.

    Returns:
        DistinctiveFeaturePlot: Configured Plotly plot object.

    Raises:
        ValueError: If the frequency table is empty or if the target text is not found.
    """
    if self.frequencies.empty:
        raise ValueError("Frequency data is required for get_feature_score_plot.")
    if target_text not in self.frequencies.index:
        raise ValueError(f"Target text '{target_text}' not found in corpus.")

    return DistinctiveFeaturePlot(
        frequencies=self.frequencies,
        target_text=target_text,
        top=top,
        title=title,
        positive_color=positive_color,
        negative_color=negative_color,
        guide_color=guide_color,
        zero_line_color=zero_line_color,
        width=width,
        height=height,
    )
members: true

The comparison Classes¤

ComparisonPlot pydantic-model ¤

Bases: BaseModel

Base class for stylometric comparison plots.

Config:

  • arbitrary_types_allowed: True
  • validate_assignment: True

Fields:

Source code in lexos/cluster/seetrees/comparison.py
class ComparisonPlot(BaseModel):
    """Base class for stylometric comparison plots."""

    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Term frequency table."
    )
    source_text: str = Field(default="", description="Label for the source text.")
    target_text: str = Field(default="", description="Label for the target text.")
    top_diff: int = Field(
        default=10, description="Number of top differences to highlight."
    )
    max_rank: int = Field(
        default=100, description="Limit for the number of features to rank."
    )
    title: str | None = Field(
        default=None, description="Optional title for the comparison plot."
    )
    fig: plt.Figure | None = Field(
        default=None, description="Matplotlib figure object."
    )

    model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)

    def _apply_figure_layout(
        self,
        fig,
        left: float = 0.06,
        right: float = 0.96,
        top: float = 0.94,
        bottom: float = 0.12,
    ):
        """Apply a compact layout to a Matplotlib Figure."""
        fig.subplots_adjust(left=left, right=right, top=top, bottom=bottom)
        self._disable_canvas_bbox_inches(fig)

    def _disable_canvas_bbox_inches(self, fig):
        """Disable bbox_inches overrides on the figure's canvas print method."""
        canvas = getattr(fig, "canvas", None)
        if canvas is None or not hasattr(canvas, "print_figure"):
            return

        original_print_figure = canvas.print_figure

        def _print_figure_no_bbox_inches(*args, **kwargs):
            kwargs.pop("bbox_inches", None)
            return original_print_figure(*args, **kwargs)

        canvas.print_figure = _print_figure_no_bbox_inches

    def _z_scores(self) -> pd.DataFrame:
        """Compute z-scores for the frequency table.

        Returns:
            pd.DataFrame: Z-scores for the frequency table.
        """
        z_scores = (self.frequencies - self.frequencies.mean()) / self.frequencies.std()
        return z_scores.fillna(0)

    def _feature_order(self) -> list[str]:
        """Determine the order of features based on mean frequency.

        Returns:
            list[str]: Ordered list of feature names based on mean frequency.
        """
        return (
            self.frequencies.mean()
            .sort_values(ascending=False)
            .index.tolist()[: self.max_rank]
        )

frequencies: pd.DataFrame pydantic-field ¤

Term frequency table.

source_text: str = '' pydantic-field ¤

Label for the source text.

target_text: str = '' pydantic-field ¤

Label for the target text.

top_diff: int = 10 pydantic-field ¤

Number of top differences to highlight.

max_rank: int = 100 pydantic-field ¤

Limit for the number of features to rank.

title: str | None = None pydantic-field ¤

Optional title for the comparison plot.

fig: plt.Figure | None = None pydantic-field ¤

Matplotlib figure object.

members: true

DifferencePlot pydantic-model ¤

Bases: ComparisonPlot

Plot the z-score difference between two texts.

Fields:

Source code in lexos/cluster/seetrees/comparison.py
class DifferencePlot(ComparisonPlot):
    """Plot the z-score difference between two texts."""

    base_color: str = Field(
        default="gray", description="Color used for non-highlighted difference bars."
    )
    highlight_color: str = Field(
        default="red", description="Color used for highlighted difference bars."
    )
    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Term frequency table."
    )
    source_text: str = Field(default="", description="Label for the source text.")
    target_text: str = Field(default="", description="Label for the target text.")
    top_diff: int = Field(
        default=10, description="Number of top differences to highlight."
    )
    max_rank: int = Field(
        default=100, description="Limit for the number of features to rank."
    )
    title: str | None = Field(
        default=None, description="Optional title for the comparison plot."
    )
    fig: plt.Figure | None = Field(
        default=None, description="Matplotlib figure object."
    )

    def __init__(self, **data):
        """Initialize the DifferencePlot with given data."""
        super().__init__(**data)
        self.fig = self.plot_difference()
        self._apply_figure_layout(self.fig)

    def plot_difference(self) -> plt.Figure:
        """Plot the difference in z-scores between the source and target texts.

        Returns:
            plt.Figure: Matplotlib figure containing the difference plot.
        """
        z_scores = self._z_scores()
        src_profile = z_scores.loc[self.source_text]
        tgt_profile = z_scores.loc[self.target_text]
        feature_order = self._feature_order()
        diff_series = (tgt_profile - src_profile).loc[feature_order].astype(float)

        df_comp = pd.DataFrame(
            {
                "Feature": feature_order,
                "Rank": np.arange(1, len(feature_order) + 1),
                "Difference": diff_series.values,
            }
        )

        fig, ax = plt.subplots(figsize=(12, 6))
        fig.subplots_adjust(left=0.12, right=0.96, top=0.94, bottom=0.12)
        diff_mask = df_comp["Difference"].abs().nlargest(self.top_diff).index
        fill_colors = np.where(
            df_comp.index.isin(diff_mask),
            self.highlight_color,
            self.base_color,
        )

        ax.bar(
            df_comp["Rank"],
            df_comp["Difference"],
            color=fill_colors,
            edgecolor="grey",
            width=0.8,
        )
        ax.axhline(0, color="black", linestyle="-", alpha=0.7)

        if not self.title:
            ax.set_title(
                f"Z-Score Differences: {self.target_text} minus {self.source_text}"
            )
        else:
            ax.set_title(self.title)
        ax.set_xlabel("Feature frequency rank")
        ax.set_ylabel("Difference between z-scores")

        ax.set_xlim(0.0, len(feature_order) + 1)
        ax.set_xticks(np.arange(0, len(feature_order) + 1, 25))
        ax.set_xticklabels(
            [str(int(tick)) for tick in np.arange(0, len(feature_order) + 1, 25)],
            rotation=45,
            ha="right",
            fontsize=9,
        )
        ax.set_yticks(
            np.arange(
                int(np.floor(ax.get_ylim()[0])),
                int(np.ceil(ax.get_ylim()[1])) + 1,
                1,
            )
        )

        ax.set_facecolor("white")
        ax.grid(False)

        top_feature_set = set(df_comp.loc[diff_mask, "Feature"])
        y_min, y_max = ax.get_ylim()
        label_offset = (y_max - y_min) * 0.03
        for bar, feature, diff in zip(
            ax.patches, df_comp["Feature"], df_comp["Difference"]
        ):
            if feature not in top_feature_set:
                continue
            rank = bar.get_x() + bar.get_width() / 2
            y = diff + (label_offset if diff >= 0 else -label_offset)
            va = "bottom" if diff >= 0 else "top"
            ax.text(
                rank,
                y,
                sanitize_label_text(feature),
                ha="center",
                va=va,
                fontsize=8,
                color="black",
                clip_on=False,
            )

        supp_text = [
            (0.01, 0.95, f"more in {self.source_text}"),
            (0.01, 0.05, f"more in {self.target_text}"),
        ]
        ax.text(
            supp_text[0][0],
            supp_text[0][1],
            supp_text[0][2],
            transform=ax.transAxes,
            rotation=90,
            va="top",
            ha="left",
            color="pink",
            fontsize=9,
        )
        ax.text(
            supp_text[1][0],
            supp_text[1][1],
            supp_text[1][2],
            transform=ax.transAxes,
            rotation=90,
            va="bottom",
            ha="left",
            color="lightblue",
            fontsize=9,
        )

        return fig

    def show(self) -> None:
        """Display the difference plot, creating it if necessary."""
        if self.fig is None:
            self.fig = self.plot_difference()
        plt.show()

base_color: str = 'gray' pydantic-field ¤

Color used for non-highlighted difference bars.

highlight_color: str = 'red' pydantic-field ¤

Color used for highlighted difference bars.

frequencies: pd.DataFrame pydantic-field ¤

Term frequency table.

source_text: str = '' pydantic-field ¤

Label for the source text.

target_text: str = '' pydantic-field ¤

Label for the target text.

top_diff: int = 10 pydantic-field ¤

Number of top differences to highlight.

max_rank: int = 100 pydantic-field ¤

Limit for the number of features to rank.

title: str | None = None pydantic-field ¤

Optional title for the comparison plot.

__init__(**data) ¤

Initialize the DifferencePlot with given data.

Source code in lexos/cluster/seetrees/comparison.py
def __init__(self, **data):
    """Initialize the DifferencePlot with given data."""
    super().__init__(**data)
    self.fig = self.plot_difference()
    self._apply_figure_layout(self.fig)

plot_difference() -> plt.Figure ¤

Plot the difference in z-scores between the source and target texts.

Returns:

Type Description
Figure

plt.Figure: Matplotlib figure containing the difference plot.

Source code in lexos/cluster/seetrees/comparison.py
def plot_difference(self) -> plt.Figure:
    """Plot the difference in z-scores between the source and target texts.

    Returns:
        plt.Figure: Matplotlib figure containing the difference plot.
    """
    z_scores = self._z_scores()
    src_profile = z_scores.loc[self.source_text]
    tgt_profile = z_scores.loc[self.target_text]
    feature_order = self._feature_order()
    diff_series = (tgt_profile - src_profile).loc[feature_order].astype(float)

    df_comp = pd.DataFrame(
        {
            "Feature": feature_order,
            "Rank": np.arange(1, len(feature_order) + 1),
            "Difference": diff_series.values,
        }
    )

    fig, ax = plt.subplots(figsize=(12, 6))
    fig.subplots_adjust(left=0.12, right=0.96, top=0.94, bottom=0.12)
    diff_mask = df_comp["Difference"].abs().nlargest(self.top_diff).index
    fill_colors = np.where(
        df_comp.index.isin(diff_mask),
        self.highlight_color,
        self.base_color,
    )

    ax.bar(
        df_comp["Rank"],
        df_comp["Difference"],
        color=fill_colors,
        edgecolor="grey",
        width=0.8,
    )
    ax.axhline(0, color="black", linestyle="-", alpha=0.7)

    if not self.title:
        ax.set_title(
            f"Z-Score Differences: {self.target_text} minus {self.source_text}"
        )
    else:
        ax.set_title(self.title)
    ax.set_xlabel("Feature frequency rank")
    ax.set_ylabel("Difference between z-scores")

    ax.set_xlim(0.0, len(feature_order) + 1)
    ax.set_xticks(np.arange(0, len(feature_order) + 1, 25))
    ax.set_xticklabels(
        [str(int(tick)) for tick in np.arange(0, len(feature_order) + 1, 25)],
        rotation=45,
        ha="right",
        fontsize=9,
    )
    ax.set_yticks(
        np.arange(
            int(np.floor(ax.get_ylim()[0])),
            int(np.ceil(ax.get_ylim()[1])) + 1,
            1,
        )
    )

    ax.set_facecolor("white")
    ax.grid(False)

    top_feature_set = set(df_comp.loc[diff_mask, "Feature"])
    y_min, y_max = ax.get_ylim()
    label_offset = (y_max - y_min) * 0.03
    for bar, feature, diff in zip(
        ax.patches, df_comp["Feature"], df_comp["Difference"]
    ):
        if feature not in top_feature_set:
            continue
        rank = bar.get_x() + bar.get_width() / 2
        y = diff + (label_offset if diff >= 0 else -label_offset)
        va = "bottom" if diff >= 0 else "top"
        ax.text(
            rank,
            y,
            sanitize_label_text(feature),
            ha="center",
            va=va,
            fontsize=8,
            color="black",
            clip_on=False,
        )

    supp_text = [
        (0.01, 0.95, f"more in {self.source_text}"),
        (0.01, 0.05, f"more in {self.target_text}"),
    ]
    ax.text(
        supp_text[0][0],
        supp_text[0][1],
        supp_text[0][2],
        transform=ax.transAxes,
        rotation=90,
        va="top",
        ha="left",
        color="pink",
        fontsize=9,
    )
    ax.text(
        supp_text[1][0],
        supp_text[1][1],
        supp_text[1][2],
        transform=ax.transAxes,
        rotation=90,
        va="bottom",
        ha="left",
        color="lightblue",
        fontsize=9,
    )

    return fig

show() -> None ¤

Display the difference plot, creating it if necessary.

Source code in lexos/cluster/seetrees/comparison.py
def show(self) -> None:
    """Display the difference plot, creating it if necessary."""
    if self.fig is None:
        self.fig = self.plot_difference()
    plt.show()
members: true

OverlayPlot pydantic-model ¤

Bases: ComparisonPlot

Plot the stylometric overlay of two texts.

Fields:

Source code in lexos/cluster/seetrees/comparison.py
class OverlayPlot(ComparisonPlot):
    """Plot the stylometric overlay of two texts."""

    source_color: str = Field(
        default="#ff9999", description="Color used for the source text overlay line."
    )
    target_color: str = Field(
        default="#99c2ff", description="Color used for the target text overlay line."
    )
    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Term frequency table."
    )
    source_text: str = Field(default="", description="Label for the source text.")
    target_text: str = Field(default="", description="Label for the target text.")
    top_diff: int = Field(
        default=10, description="Number of top differences to highlight."
    )
    max_rank: int = Field(
        default=100, description="Limit for the number of features to rank."
    )
    title: str | None = Field(
        default=None, description="Optional title for the comparison plot."
    )
    fig: plt.Figure | None = Field(
        default=None, description="Matplotlib figure object."
    )

    def __init__(self, **data):
        """Initialize the OverlayPlot with given data."""
        super().__init__(**data)
        self.fig = self.plot_overlay()
        self._apply_figure_layout(self.fig)

    def plot_overlay(self) -> plt.Figure:
        """Plot the stylometric overlays of the source and target texts.

        Returns:
            plt.Figure: Matplotlib figure containing the overlay plot.
        """
        z_scores = self._z_scores()
        src_profile = z_scores.loc[self.source_text]
        tgt_profile = z_scores.loc[self.target_text]
        feature_order = self._feature_order()
        x = np.arange(1, len(feature_order) + 1)
        src_values = src_profile.loc[feature_order].astype(float).to_numpy()
        tgt_values = tgt_profile.loc[feature_order].astype(float).to_numpy()

        fig, ax = plt.subplots(figsize=(12, 6))
        fig.patch.set_facecolor("white")
        ax.set_facecolor("white")
        ax.grid(False)
        ax.plot(
            x,
            src_values,
            color=self.source_color,
            linewidth=1.8,
            label=self.source_text,
            alpha=0.85,
        )
        ax.plot(
            x,
            tgt_values,
            color=self.target_color,
            linewidth=1.8,
            label=self.target_text,
            alpha=0.85,
        )

        ax.axhline(0, color="black", linestyle="-", linewidth=1)
        for sd in [-2, -1, 1, 2]:
            ax.axhline(sd, color="gray", linestyle="--", alpha=0.45, linewidth=0.8)

        combined_scores = np.maximum(np.abs(src_values), np.abs(tgt_values))
        peak_indices = np.argsort(combined_scores)[-self.top_diff :][::-1]

        for idx in peak_indices:
            label = feature_order[idx]
            x_pos = x[idx]
            y_value = (
                src_values[idx]
                if abs(src_values[idx]) >= abs(tgt_values[idx])
                else tgt_values[idx]
            )
            y_offset = 0.08 if y_value >= 0 else -0.08
            ax.plot(
                [x_pos, x_pos],
                [0, y_value],
                color="gray",
                linestyle=":",
                linewidth=0.8,
                alpha=0.7,
            )
            ax.scatter([x_pos], [y_value], color="black", s=20)
            ax.text(
                x_pos,
                y_value + y_offset,
                sanitize_label_text(label),
                ha="center",
                va="bottom" if y_value >= 0 else "top",
                fontsize=9,
                color="black",
            )

        ax.set_xlim(0.5, len(feature_order) + 0.5)
        if not self.title:
            ax.set_title(
                f"Stylometric Overlay: {self.source_text} vs {self.target_text}"
            )
        else:
            ax.set_title(self.title)
        ax.set_xlabel("Feature frequency rank")
        ax.set_ylabel("Standard deviation from the corpus mean")
        ax.tick_params(axis="y", labelleft=True)
        ax.yaxis.set_ticks_position("left")
        return fig

    def show(self) -> None:
        """Display the overlay plot, creating it if necessary."""
        if self.fig is None:
            self.fig = self.plot_overlay()
        plt.show()

source_color: str = '#ff9999' pydantic-field ¤

Color used for the source text overlay line.

target_color: str = '#99c2ff' pydantic-field ¤

Color used for the target text overlay line.

frequencies: pd.DataFrame pydantic-field ¤

Term frequency table.

source_text: str = '' pydantic-field ¤

Label for the source text.

target_text: str = '' pydantic-field ¤

Label for the target text.

top_diff: int = 10 pydantic-field ¤

Number of top differences to highlight.

max_rank: int = 100 pydantic-field ¤

Limit for the number of features to rank.

title: str | None = None pydantic-field ¤

Optional title for the comparison plot.

__init__(**data) ¤

Initialize the OverlayPlot with given data.

Source code in lexos/cluster/seetrees/comparison.py
def __init__(self, **data):
    """Initialize the OverlayPlot with given data."""
    super().__init__(**data)
    self.fig = self.plot_overlay()
    self._apply_figure_layout(self.fig)

plot_overlay() -> plt.Figure ¤

Plot the stylometric overlays of the source and target texts.

Returns:

Type Description
Figure

plt.Figure: Matplotlib figure containing the overlay plot.

Source code in lexos/cluster/seetrees/comparison.py
def plot_overlay(self) -> plt.Figure:
    """Plot the stylometric overlays of the source and target texts.

    Returns:
        plt.Figure: Matplotlib figure containing the overlay plot.
    """
    z_scores = self._z_scores()
    src_profile = z_scores.loc[self.source_text]
    tgt_profile = z_scores.loc[self.target_text]
    feature_order = self._feature_order()
    x = np.arange(1, len(feature_order) + 1)
    src_values = src_profile.loc[feature_order].astype(float).to_numpy()
    tgt_values = tgt_profile.loc[feature_order].astype(float).to_numpy()

    fig, ax = plt.subplots(figsize=(12, 6))
    fig.patch.set_facecolor("white")
    ax.set_facecolor("white")
    ax.grid(False)
    ax.plot(
        x,
        src_values,
        color=self.source_color,
        linewidth=1.8,
        label=self.source_text,
        alpha=0.85,
    )
    ax.plot(
        x,
        tgt_values,
        color=self.target_color,
        linewidth=1.8,
        label=self.target_text,
        alpha=0.85,
    )

    ax.axhline(0, color="black", linestyle="-", linewidth=1)
    for sd in [-2, -1, 1, 2]:
        ax.axhline(sd, color="gray", linestyle="--", alpha=0.45, linewidth=0.8)

    combined_scores = np.maximum(np.abs(src_values), np.abs(tgt_values))
    peak_indices = np.argsort(combined_scores)[-self.top_diff :][::-1]

    for idx in peak_indices:
        label = feature_order[idx]
        x_pos = x[idx]
        y_value = (
            src_values[idx]
            if abs(src_values[idx]) >= abs(tgt_values[idx])
            else tgt_values[idx]
        )
        y_offset = 0.08 if y_value >= 0 else -0.08
        ax.plot(
            [x_pos, x_pos],
            [0, y_value],
            color="gray",
            linestyle=":",
            linewidth=0.8,
            alpha=0.7,
        )
        ax.scatter([x_pos], [y_value], color="black", s=20)
        ax.text(
            x_pos,
            y_value + y_offset,
            sanitize_label_text(label),
            ha="center",
            va="bottom" if y_value >= 0 else "top",
            fontsize=9,
            color="black",
        )

    ax.set_xlim(0.5, len(feature_order) + 0.5)
    if not self.title:
        ax.set_title(
            f"Stylometric Overlay: {self.source_text} vs {self.target_text}"
        )
    else:
        ax.set_title(self.title)
    ax.set_xlabel("Feature frequency rank")
    ax.set_ylabel("Standard deviation from the corpus mean")
    ax.tick_params(axis="y", labelleft=True)
    ax.yaxis.set_ticks_position("left")
    return fig

show() -> None ¤

Display the overlay plot, creating it if necessary.

Source code in lexos/cluster/seetrees/comparison.py
def show(self) -> None:
    """Display the overlay plot, creating it if necessary."""
    if self.fig is None:
        self.fig = self.plot_overlay()
    plt.show()
members: true

The density_plot Class¤

DensityPlot pydantic-model ¤

Bases: BaseModel

Encapsulate view_distances plotting logic as a density plot.

Config:

  • arbitrary_types_allowed: True

Fields:

Source code in lexos/cluster/seetrees/density_plot.py
class DensityPlot(BaseModel):
    """Encapsulate view_distances plotting logic as a density plot."""

    model_config = ConfigDict(arbitrary_types_allowed=True)

    distance_table: pd.DataFrame = Field(
        ...,
        description="Distance table containing pairwise distances between items.",
    )
    labels: list[str] = Field(
        ...,
        description="List of labels corresponding to the items in the distance table.",
    )
    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame,
        description="Optional frequency table for the items.",
    )
    author: str | None = Field(
        default=None, description="Specific author to highlight in the plot."
    )
    group: bool = Field(
        default=True,
        description="Whether to group distances by same author.",
    )
    pattern: str = Field(
        default=r"^.*?(?=[_\s-]|\d)",
        description="Regular expression pattern to extract author classes from labels.",
    )
    title: str | None = Field(
        default=None,
        description="Optional title for the density plot.",
    )
    palette: dict[str, str] | None = Field(
        default=None,
        description="Optional color palette for grouped density curves. Keys should be 'True' and 'False'.",
    )
    color: str = Field(
        default="#cccccc",
        description="Fill color for ungrouped density plots.",
    )
    left: float = Field(default=0.14, description="Left margin for the figure layout.")
    show_on_init: bool = Field(
        default=False,
        description="Whether to display the plot immediately when the object is created.",
    )
    right: float = Field(
        default=0.95, description="Right margin for the figure layout."
    )
    top: float = Field(default=0.92, description="Top margin for the figure layout.")
    bottom: float = Field(
        default=0.15, description="Bottom margin for the figure layout."
    )
    fig: plt.Figure | None = Field(
        default=None, description="Matplotlib figure containing the density plot."
    )

    def __init__(self, **data):
        """Initialize the DensityPlot object."""
        super().__init__(**data)
        self.labels = list(self.labels)
        self.frequencies = (
            self.frequencies if self.frequencies is not None else pd.DataFrame()
        )
        if self.author is not None and self.pattern is not None:
            classes = [
                self._extract_label_class(label, self.pattern) for label in self.labels
            ]
            if self.author not in classes:
                raise ValueError(
                    f"Author '{self.author}' is not present in the distance table."
                )
        if self.show_on_init:
            self.plot_density()
            plt.show()

    def _add_legend(self, ax):
        handles, labels = ax.get_legend_handles_labels()
        legend_items = [
            (h, l) for h, l in zip(handles, labels) if l and not l.startswith("_")
        ]
        if legend_items:
            handles, labels = zip(*legend_items)
            legend = ax.legend(
                handles,
                labels,
                title="",
                loc="upper center",
                bbox_to_anchor=(0.5, 1.08),
                ncol=2,
            )
            if legend is not None:
                legend.set_title("")

    def _build_density_frame(self, pattern: str) -> pd.DataFrame:
        """Build a DataFrame suitable for density plotting.

        Args:
            pattern (str): Regular expression pattern to extract author classes from labels.

        Returns:
            pd.DataFrame: DataFrame containing distances, same_author flags, and classes.
        """
        labels = list(self.distance_table.index)
        classes = [self._extract_label_class(label, pattern) for label in labels]

        matrix = self.distance_table.to_numpy()
        lower = np.tril_indices_from(matrix, k=-1)
        dvals = matrix[lower]
        pairs = list(zip(lower[0], lower[1]))
        same_author = [classes[i] == classes[j] for i, j in pairs]
        pair_class = [classes[i] for i, _ in pairs]

        return pd.DataFrame(
            {
                "d": dvals,
                "same_author": np.array(same_author).astype(str),
                "class": pair_class,
            }
        )

    def _create_figure(self):
        """Create the base figure and axes for the density plot."""
        fig, ax = plt.subplots(figsize=(8, 6))
        fig.patch.set_facecolor("white")
        ax.set_facecolor("white")
        ax.grid(False)
        return fig, ax

    def _disable_canvas_bbox_inches(self, fig):
        """Disable bbox_inches overrides on the figure's canvas print method."""
        canvas = getattr(fig, "canvas", None)
        if canvas is None or not hasattr(canvas, "print_figure"):
            return

        original_print_figure = canvas.print_figure

        def _print_figure_no_bbox_inches(*args, **kwargs):
            kwargs.pop("bbox_inches", None)
            return original_print_figure(*args, **kwargs)

        canvas.print_figure = _print_figure_no_bbox_inches

    def _extract_label_class(self, label: str, pattern: str) -> str:
        """Extract a class label from a document label using a fallback strategy."""
        match = re.search(pattern, label)
        if match:
            return match.group(0)

        for sep in ["_", " ", "-"]:
            if sep in label:
                return label.split(sep, 1)[0]

        digit_match = re.match(r"^(.+?)(?:\d+)$", label)
        if digit_match:
            return digit_match.group(1)

        return label

    def _finalize_axes(self, ax, df: pd.DataFrame):
        ax.set_xlim(left=0)
        ax.margins(x=0.05, y=0.05)
        ymin, ymax = ax.get_ylim()
        if ymin >= 0:
            ax.set_ylim(bottom=-0.05 * max(ymax, 1.0))
        ax.set_xlabel("Distance")
        ax.set_ylabel("Density")
        if self.title is not None:
            ax.set_title(self.title)
        elif self.author is not None:
            self._highlight_author(ax, df)
        sns.despine(ax=ax)

    def _finalize_figure(self, fig):
        fig.subplots_adjust(
            left=self.left, right=self.right, top=self.top, bottom=self.bottom
        )
        self._disable_canvas_bbox_inches(fig)

    def _get_palette(self) -> dict[str, str]:
        if self.palette is None:
            return {"True": "pink", "False": "lightblue"}
        return {
            "True": self.palette.get("True", "pink"),
            "False": self.palette.get("False", "lightblue"),
        }

    def _highlight_author(self, ax, df: pd.DataFrame):
        author_distances = df[df["class"] == self.author]
        ax.scatter(
            author_distances["d"],
            np.zeros(len(author_distances)),
            s=40,
            color="black",
            zorder=10,
        )
        ax.set_title(f"Points: distances between works of {self.author}")

    def _plot_grouped_density(self, ax, df: pd.DataFrame):
        counts = df["same_author"].value_counts().to_dict()
        true_count = int(counts.get("True", 0))
        false_count = int(counts.get("False", 0))
        if true_count < 2 or false_count < 2:
            warnings.warn(
                "Grouped density is unlikely to be meaningful because "
                f"there are only {true_count} same-author distance pair(s) "
                f"and {false_count} different-author distance pair(s). "
                "If you want a meaningful grouped density, add more same-author "
                "documents, verify that label grouping is correct, or use "
                "group=False for the overall distance density.",
                UserWarning,
                stacklevel=2,
            )
        palette = self._get_palette()
        sns.kdeplot(
            data=df,
            x="d",
            hue="same_author",
            fill=True,
            multiple="layer",
            alpha=0.55,
            palette=palette,
            common_norm=False,
            warn_singular=False,
            ax=ax,
            linewidth=0,
            legend=False,
        )
        meds = df.groupby("same_author")["d"].median()
        for dval in meds:
            ax.axvline(dval, linestyle="--", color="white", linewidth=1)
        self._add_legend(ax)

    def _plot_ungrouped_density(self, ax, df: pd.DataFrame):
        sns.kdeplot(
            data=df,
            x="d",
            fill=True,
            color=self.color,
            alpha=0.5,
            warn_singular=False,
            ax=ax,
            linewidth=0,
        )
        ax.axvline(df["d"].median(), linestyle="--", color="white", linewidth=1)

    def plot_density(self):
        """Plot the density of distances, optionally grouped by author."""
        df = self._build_density_frame(self.pattern)
        with plt.ioff():
            fig, ax = self._create_figure()
            if self.group:
                self._plot_grouped_density(ax, df)
            else:
                self._plot_ungrouped_density(ax, df)
            self._finalize_axes(ax, df)
            self._finalize_figure(fig)
        self.fig = fig

    def show(self):
        """Display the density plot."""
        if self.fig is None:
            self.plot_density()
        plt.show()

distance_table: pd.DataFrame pydantic-field ¤

Distance table containing pairwise distances between items.

author: str | None = None pydantic-field ¤

Specific author to highlight in the plot.

group: bool = True pydantic-field ¤

Whether to group distances by same author.

pattern: str = '^.*?(?=[_\\s-]|\\d)' pydantic-field ¤

Regular expression pattern to extract author classes from labels.

title: str | None = None pydantic-field ¤

Optional title for the density plot.

palette: dict[str, str] | None = None pydantic-field ¤

Optional color palette for grouped density curves. Keys should be 'True' and 'False'.

color: str = '#cccccc' pydantic-field ¤

Fill color for ungrouped density plots.

left: float = 0.14 pydantic-field ¤

Left margin for the figure layout.

show_on_init: bool = False pydantic-field ¤

Whether to display the plot immediately when the object is created.

right: float = 0.95 pydantic-field ¤

Right margin for the figure layout.

top: float = 0.92 pydantic-field ¤

Top margin for the figure layout.

bottom: float = 0.15 pydantic-field ¤

Bottom margin for the figure layout.

fig: plt.Figure | None = None pydantic-field ¤

Matplotlib figure containing the density plot.

__init__(**data) ¤

Initialize the DensityPlot object.

Source code in lexos/cluster/seetrees/density_plot.py
def __init__(self, **data):
    """Initialize the DensityPlot object."""
    super().__init__(**data)
    self.labels = list(self.labels)
    self.frequencies = (
        self.frequencies if self.frequencies is not None else pd.DataFrame()
    )
    if self.author is not None and self.pattern is not None:
        classes = [
            self._extract_label_class(label, self.pattern) for label in self.labels
        ]
        if self.author not in classes:
            raise ValueError(
                f"Author '{self.author}' is not present in the distance table."
            )
    if self.show_on_init:
        self.plot_density()
        plt.show()

plot_density() ¤

Plot the density of distances, optionally grouped by author.

Source code in lexos/cluster/seetrees/density_plot.py
def plot_density(self):
    """Plot the density of distances, optionally grouped by author."""
    df = self._build_density_frame(self.pattern)
    with plt.ioff():
        fig, ax = self._create_figure()
        if self.group:
            self._plot_grouped_density(ax, df)
        else:
            self._plot_ungrouped_density(ax, df)
        self._finalize_axes(ax, df)
        self._finalize_figure(fig)
    self.fig = fig

show() ¤

Display the density plot.

Source code in lexos/cluster/seetrees/density_plot.py
def show(self):
    """Display the density plot."""
    if self.fig is None:
        self.plot_density()
    plt.show()
members: true

The projection_plot Classes¤

ProjectionPlot pydantic-model ¤

Bases: BaseModel

Base class for projection plots with lazy figure creation.

Config:

  • arbitrary_types_allowed: True

Fields:

Source code in lexos/cluster/seetrees/projection_plot.py
class ProjectionPlot(BaseModel):
    """Base class for projection plots with lazy figure creation."""

    model_config = ConfigDict(arbitrary_types_allowed=True)

    author: str | None = Field(
        default=None, description="Specific author to highlight in the plot."
    )
    pattern: str = Field(
        default=r"^.*?(?=[_\s-]|\d)",
        description="Regular expression pattern to extract author classes from labels.",
    )
    distance_table: pd.DataFrame = Field(
        ...,
        description="Distance table containing pairwise distances between items.",
    )
    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame,
        description="Optional frequency table for the items.",
    )
    labels: list[str] = Field(
        ...,
        description="List of labels corresponding to the items in the distance table.",
    )
    title: str | None = Field(
        default=None,
        description="Optional title for the projection plot.",
    )
    left: float = Field(default=0.12, description="Left margin for the figure layout.")
    right: float = Field(
        default=0.96, description="Right margin for the figure layout."
    )
    top: float = Field(default=0.94, description="Top margin for the figure layout.")
    bottom: float = Field(
        default=0.12, description="Bottom margin for the figure layout."
    )
    show_on_init: bool = Field(
        default=False,
        description="Whether to display the plot immediately when the object is created.",
    )
    fig: plt.Figure | None = Field(
        default=None, description="Matplotlib figure containing the projection plot."
    )

    def __init__(self, **data):
        """Initialize the ProjectionPlot object."""
        super().__init__(**data)
        self.labels = list(self.labels)
        self.frequencies = (
            self.frequencies if self.frequencies is not None else pd.DataFrame()
        )
        if self.author is not None and self.pattern is not None:
            classes = [
                self._extract_label_class(label, self.pattern) for label in self.labels
            ]
            if self.author not in classes:
                raise ValueError(
                    f"Author '{self.author}' is not present in the distance table."
                )
        if self.show_on_init:
            self.plot_projection()
            plt.show()

    def _build_density_frame(self, pattern: str) -> pd.DataFrame:
        """Build a DataFrame suitable for density plotting.

        Args:
            pattern (str): Regular expression pattern to extract author classes from labels.

        Returns:
            pd.DataFrame: DataFrame containing distances, same_author flags, and classes.
        """
        labels = list(self.distance_table.index)
        classes = [self._extract_label_class(label, pattern) for label in labels]

        matrix = self.distance_table.to_numpy()
        lower = np.tril_indices_from(matrix, k=-1)
        dvals = matrix[lower]
        pairs = list(zip(lower[0], lower[1]))
        same_author = [classes[i] == classes[j] for i, j in pairs]
        pair_class = [classes[i] for i, _ in pairs]

        return pd.DataFrame(
            {
                "d": dvals,
                "same_author": np.array(same_author).astype(str),
                "class": pair_class,
            }
        )

    def _compute_coords(self) -> np.ndarray:
        """Compute the coordinates for the projection plot.

        Returns:
            np.ndarray: The computed coordinates for the projection plot.
        """
        raise NotImplementedError

    def _create_figure(self) -> tuple[plt.Figure, plt.Axes]:
        """Create a new matplotlib figure and axes for the projection plot.

        Returns:
            tuple: A tuple containing the matplotlib figure and axes.
        """
        fig, ax = plt.subplots(figsize=(8, 6))
        fig.patch.set_facecolor("white")
        ax.set_facecolor("white")
        ax.grid(False)
        return fig, ax

    def _disable_canvas_bbox_inches(self, fig: plt.Figure) -> None:
        """Disable bbox_inches overrides on the figure's canvas print method.

        Args:
            fig (plt.Figure): The matplotlib figure whose canvas print method will be modified.
        """
        canvas = getattr(fig, "canvas", None)
        if canvas is None or not hasattr(canvas, "print_figure"):
            return

        original_print_figure = canvas.print_figure

        def _print_figure_no_bbox_inches(*args, **kwargs):
            kwargs.pop("bbox_inches", None)
            return original_print_figure(*args, **kwargs)

        canvas.print_figure = _print_figure_no_bbox_inches

    def _extract_label_class(self, label: str, pattern: str) -> str:
        """Extract a class label from a document label using a fallback strategy.

        Args:
            label (str): The document label from which to extract the class.
            pattern (str): Regular expression pattern to extract author classes from labels.

        Returns:
            str: The extracted class label.
        """
        match = re.search(pattern, label)
        if match:
            return match.group(0)

        for sep in ["_", " ", "-"]:
            if sep in label:
                return label.split(sep, 1)[0]

        digit_match = re.match(r"^(.+?)(?:\d+)$", label)
        if digit_match:
            return digit_match.group(1)

        return label

    def _finalize_figure(self, fig: plt.Figure) -> None:
        """Finalize the figure layout and disable bbox_inches overrides.

        Args:
            fig (plt.Figure): The matplotlib figure to finalize.

        Returns:
            None
        """
        fig.subplots_adjust(
            left=self.left, right=self.right, top=self.top, bottom=self.bottom
        )
        self._disable_canvas_bbox_inches(fig)

    def _highlight_author(self, ax: plt.Axes, coords: np.ndarray) -> None:
        """Highlight points corresponding to a specific author on the plot.

        Args:
            ax (plt.Axes): The matplotlib axes on which to highlight the author.
            coords (np.ndarray): The coordinates of the plotted points.
        """
        if self.author is None:
            return

        classes = [
            self._extract_label_class(label, self.pattern) for label in self.labels
        ]
        author_mask = np.array([class_name == self.author for class_name in classes])
        if not author_mask.any():
            return

        ax.scatter(
            coords[author_mask, 0],
            coords[author_mask, 1],
            facecolors="none",
            edgecolors="black",
            linewidths=2,
            s=200,
            zorder=12,
        )
        if self.title is None:
            ax.set_title(f"Highlights for {self.author}")

    def _offset_ratio(self) -> float:
        """Return the offset ratio for label placement in the plot.

        Returns:
            float: The offset ratio for label placement.

        Raises:
            NotImplementedError: If the method is not implemented in a subclass.
        """
        raise NotImplementedError

    def _plot_base(self, ax: plt.Axes, coords: np.ndarray, title_suffix: str) -> None:
        """Plot the base scatter plot and labels on the provided axes.

        Args:
            ax (plt.Axes): The matplotlib axes on which to plot.
            coords (np.ndarray): The coordinates of the points to plot.
            title_suffix (str): Suffix for the plot title indicating the projection method.

        Returns:
            None
        """
        classes = [
            self._extract_label_class(label, self.pattern) for label in self.labels
        ]
        unique_classes = sorted(set(classes))
        palette = plt.cm.get_cmap("tab10", len(unique_classes))
        class_colors = {cls: palette(i) for i, cls in enumerate(unique_classes)}

        for i, label in enumerate(self.labels):
            cls = classes[i]
            ax.scatter(
                coords[i, 0],
                coords[i, 1],
                color=class_colors[cls],
                edgecolors="black",
                s=100,
                alpha=0.8,
            )
            x_offset = np.ptp(coords[:, 0]) * self._offset_ratio()
            y_offset = np.ptp(coords[:, 1]) * self._offset_ratio()
            if x_offset == 0:
                x_offset = 0.5
            if y_offset == 0:
                y_offset = 0.5
            ax.text(
                coords[i, 0] + x_offset,
                coords[i, 1] + y_offset,
                sanitize_label_text(label),
                fontsize=9,
                color=class_colors[cls],
                alpha=0.8,
            )

        ax.set_title(self.title or f"Stylometric Distribution via {title_suffix}")
        ax.set_xlabel("Dimension 1")
        ax.set_ylabel("Dimension 2")

    def _title_suffix(self) -> str:
        """Return a suffix for the plot title indicating the projection method.

        Returns:
            str: The title suffix for the projection plot.

        Raises:
            NotImplementedError: If the method is not implemented in a subclass.
        """
        raise NotImplementedError

    def plot_projection(self) -> None:
        """Compute coordinates, create the figure, plot the base scatter, and finalize the figure."""
        coords = self._compute_coords()
        fig, ax = self._create_figure()
        self._plot_base(ax, coords, self._title_suffix())
        self._finalize_figure(fig)
        self.fig = fig

    def show(self) -> None:
        """Display the projection plot, creating it if necessary."""
        if self.fig is None:
            self.plot_projection()
        plt.show()

author: str | None = None pydantic-field ¤

Specific author to highlight in the plot.

pattern: str = '^.*?(?=[_\\s-]|\\d)' pydantic-field ¤

Regular expression pattern to extract author classes from labels.

distance_table: pd.DataFrame pydantic-field ¤

Distance table containing pairwise distances between items.

title: str | None = None pydantic-field ¤

Optional title for the projection plot.

left: float = 0.12 pydantic-field ¤

Left margin for the figure layout.

right: float = 0.96 pydantic-field ¤

Right margin for the figure layout.

top: float = 0.94 pydantic-field ¤

Top margin for the figure layout.

bottom: float = 0.12 pydantic-field ¤

Bottom margin for the figure layout.

show_on_init: bool = False pydantic-field ¤

Whether to display the plot immediately when the object is created.

fig: plt.Figure | None = None pydantic-field ¤

Matplotlib figure containing the projection plot.

__init__(**data) ¤

Initialize the ProjectionPlot object.

Source code in lexos/cluster/seetrees/projection_plot.py
def __init__(self, **data):
    """Initialize the ProjectionPlot object."""
    super().__init__(**data)
    self.labels = list(self.labels)
    self.frequencies = (
        self.frequencies if self.frequencies is not None else pd.DataFrame()
    )
    if self.author is not None and self.pattern is not None:
        classes = [
            self._extract_label_class(label, self.pattern) for label in self.labels
        ]
        if self.author not in classes:
            raise ValueError(
                f"Author '{self.author}' is not present in the distance table."
            )
    if self.show_on_init:
        self.plot_projection()
        plt.show()

plot_projection() -> None ¤

Compute coordinates, create the figure, plot the base scatter, and finalize the figure.

Source code in lexos/cluster/seetrees/projection_plot.py
def plot_projection(self) -> None:
    """Compute coordinates, create the figure, plot the base scatter, and finalize the figure."""
    coords = self._compute_coords()
    fig, ax = self._create_figure()
    self._plot_base(ax, coords, self._title_suffix())
    self._finalize_figure(fig)
    self.fig = fig

show() -> None ¤

Display the projection plot, creating it if necessary.

Source code in lexos/cluster/seetrees/projection_plot.py
def show(self) -> None:
    """Display the projection plot, creating it if necessary."""
    if self.fig is None:
        self.plot_projection()
    plt.show()
members: true

MDS pydantic-model ¤

Bases: ProjectionPlot

Encapsulate view_distances plotting logic as an MDS projection plot.

Fields:

Source code in lexos/cluster/seetrees/projection_plot.py
class MDS(ProjectionPlot):
    """Encapsulate view_distances plotting logic as an MDS projection plot."""

    metric: str | None = Field(default=None, description="Distance metric for MDS.")
    random_state: int = Field(
        default=42, description="Random seed for reproducibility."
    )

    def _compute_coords(self) -> np.ndarray:
        """Compute the coordinates for the MDS projection plot.

        Returns:
            np.ndarray: The computed coordinates for the MDS projection plot.
        """
        return SKLearnMDS(
            n_components=2,
            dissimilarity="precomputed",
            random_state=self.random_state,
        ).fit_transform(self.distance_table.to_numpy())

    def _title_suffix(self) -> str:
        """Return a suffix for the plot title indicating the MDS projection method.

        Returns:
            str: The title suffix for the MDS projection plot.
        """
        return f"MDS (Distance Matrix via '{self.metric or 'precomputed'}')"

    def _offset_ratio(self) -> float:
        """Return the offset ratio for label placement in the MDS plot.

        Returns:
            float: The offset ratio for label placement in the MDS plot.
        """
        return 0.03

metric: str | None = None pydantic-field ¤

Distance metric for MDS.

random_state: int = 42 pydantic-field ¤

Random seed for reproducibility.

author: str | None = None pydantic-field ¤

Specific author to highlight in the plot.

pattern: str = '^.*?(?=[_\\s-]|\\d)' pydantic-field ¤

Regular expression pattern to extract author classes from labels.

distance_table: pd.DataFrame pydantic-field ¤

Distance table containing pairwise distances between items.

title: str | None = None pydantic-field ¤

Optional title for the projection plot.

left: float = 0.12 pydantic-field ¤

Left margin for the figure layout.

right: float = 0.96 pydantic-field ¤

Right margin for the figure layout.

top: float = 0.94 pydantic-field ¤

Top margin for the figure layout.

bottom: float = 0.12 pydantic-field ¤

Bottom margin for the figure layout.

show_on_init: bool = False pydantic-field ¤

Whether to display the plot immediately when the object is created.

fig: plt.Figure | None = None pydantic-field ¤

Matplotlib figure containing the projection plot.

__init__(**data) ¤

Initialize the ProjectionPlot object.

Source code in lexos/cluster/seetrees/projection_plot.py
def __init__(self, **data):
    """Initialize the ProjectionPlot object."""
    super().__init__(**data)
    self.labels = list(self.labels)
    self.frequencies = (
        self.frequencies if self.frequencies is not None else pd.DataFrame()
    )
    if self.author is not None and self.pattern is not None:
        classes = [
            self._extract_label_class(label, self.pattern) for label in self.labels
        ]
        if self.author not in classes:
            raise ValueError(
                f"Author '{self.author}' is not present in the distance table."
            )
    if self.show_on_init:
        self.plot_projection()
        plt.show()

plot_projection() -> None ¤

Compute coordinates, create the figure, plot the base scatter, and finalize the figure.

Source code in lexos/cluster/seetrees/projection_plot.py
def plot_projection(self) -> None:
    """Compute coordinates, create the figure, plot the base scatter, and finalize the figure."""
    coords = self._compute_coords()
    fig, ax = self._create_figure()
    self._plot_base(ax, coords, self._title_suffix())
    self._finalize_figure(fig)
    self.fig = fig

show() -> None ¤

Display the projection plot, creating it if necessary.

Source code in lexos/cluster/seetrees/projection_plot.py
def show(self) -> None:
    """Display the projection plot, creating it if necessary."""
    if self.fig is None:
        self.plot_projection()
    plt.show()
members: true

PCA pydantic-model ¤

Bases: ProjectionPlot

Encapsulate view_distances plotting logic as a PCA projection plot.

Fields:

Source code in lexos/cluster/seetrees/projection_plot.py
class PCA(ProjectionPlot):
    """Encapsulate view_distances plotting logic as a PCA projection plot."""

    distance_table: pd.DataFrame = Field(
        default_factory=pd.DataFrame,
        description="Optional distance table for compatibility; PCA uses frequencies.",
    )
    random_state: int = Field(
        default=42, description="Random seed for reproducibility."
    )

    def _compute_coords(self) -> np.ndarray:
        """Compute the coordinates for the PCA projection plot.

        Returns:
            np.ndarray: The computed coordinates for the PCA projection plot.
        """
        z_scores = (self.frequencies - self.frequencies.mean()) / self.frequencies.std()
        return SKLearnPCA(n_components=2, random_state=self.random_state).fit_transform(
            z_scores.fillna(0).to_numpy()
        )

    def _offset_ratio(self) -> float:
        """Return the offset ratio for label placement in the PCA plot.

        Returns:
            float: The offset ratio for label placement in the PCA plot.
        """
        return 0.015

    def _plot_base(self, ax: plt.Axes, coords: np.ndarray, title_suffix: str) -> None:
        """Plot the PCA scatter plot and labels with PCA-specific axis styling."""
        classes = [
            self._extract_label_class(label, self.pattern) for label in self.labels
        ]
        unique_classes = sorted(set(classes))
        palette = plt.cm.get_cmap("tab10", len(unique_classes))
        class_colors = {cls: palette(i) for i, cls in enumerate(unique_classes)}

        ax.axvline(0, color="lightgrey", linestyle="--", linewidth=1, zorder=0)
        ax.axhline(0, color="lightgrey", linestyle="--", linewidth=1, zorder=0)

        for i, label in enumerate(self.labels):
            cls = classes[i]
            ax.scatter(
                coords[i, 0],
                coords[i, 1],
                color=class_colors[cls],
                edgecolors="black",
                s=100,
                alpha=0.8,
            )
            x_offset = np.ptp(coords[:, 0]) * self._offset_ratio()
            y_offset = np.ptp(coords[:, 1]) * self._offset_ratio()
            if x_offset == 0:
                x_offset = 0.5
            if y_offset == 0:
                y_offset = 0.5
            ax.text(
                coords[i, 0] + x_offset,
                coords[i, 1] + y_offset,
                sanitize_label_text(label),
                fontsize=9,
                color=class_colors[cls],
                alpha=0.8,
            )

        ax.set_title(self.title or f"Stylometric Distribution via {title_suffix}")
        ax.set_xlabel("Principal Component 1")
        ax.set_ylabel("Principal Component 2")

    def _title_suffix(self) -> str:
        """Return a suffix for the plot title indicating the PCA projection method.

        Returns:
            str: The title suffix for the PCA projection plot.
        """
        return "PCA (Z-scored Profiles)"

distance_table: pd.DataFrame pydantic-field ¤

Optional distance table for compatibility; PCA uses frequencies.

random_state: int = 42 pydantic-field ¤

Random seed for reproducibility.

author: str | None = None pydantic-field ¤

Specific author to highlight in the plot.

pattern: str = '^.*?(?=[_\\s-]|\\d)' pydantic-field ¤

Regular expression pattern to extract author classes from labels.

title: str | None = None pydantic-field ¤

Optional title for the projection plot.

left: float = 0.12 pydantic-field ¤

Left margin for the figure layout.

right: float = 0.96 pydantic-field ¤

Right margin for the figure layout.

top: float = 0.94 pydantic-field ¤

Top margin for the figure layout.

bottom: float = 0.12 pydantic-field ¤

Bottom margin for the figure layout.

show_on_init: bool = False pydantic-field ¤

Whether to display the plot immediately when the object is created.

fig: plt.Figure | None = None pydantic-field ¤

Matplotlib figure containing the projection plot.

__init__(**data) ¤

Initialize the ProjectionPlot object.

Source code in lexos/cluster/seetrees/projection_plot.py
def __init__(self, **data):
    """Initialize the ProjectionPlot object."""
    super().__init__(**data)
    self.labels = list(self.labels)
    self.frequencies = (
        self.frequencies if self.frequencies is not None else pd.DataFrame()
    )
    if self.author is not None and self.pattern is not None:
        classes = [
            self._extract_label_class(label, self.pattern) for label in self.labels
        ]
        if self.author not in classes:
            raise ValueError(
                f"Author '{self.author}' is not present in the distance table."
            )
    if self.show_on_init:
        self.plot_projection()
        plt.show()

plot_projection() -> None ¤

Compute coordinates, create the figure, plot the base scatter, and finalize the figure.

Source code in lexos/cluster/seetrees/projection_plot.py
def plot_projection(self) -> None:
    """Compute coordinates, create the figure, plot the base scatter, and finalize the figure."""
    coords = self._compute_coords()
    fig, ax = self._create_figure()
    self._plot_base(ax, coords, self._title_suffix())
    self._finalize_figure(fig)
    self.fig = fig

show() -> None ¤

Display the projection plot, creating it if necessary.

Source code in lexos/cluster/seetrees/projection_plot.py
def show(self) -> None:
    """Display the projection plot, creating it if necessary."""
    if self.fig is None:
        self.plot_projection()
    plt.show()
members: true

The tree Class¤

Tree pydantic-model ¤

Bases: BaseModel

Render a stylometric dendrogram and cluster word summary.

Config:

  • arbitrary_types_allowed: True
  • validate_assignment: True

Fields:

Source code in lexos/cluster/seetrees/tree.py
 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
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
class Tree(BaseModel):
    """Render a stylometric dendrogram and cluster word summary."""

    labels: list[str] = Field(
        default_factory=list, description="List of document labels."
    )
    distance_table: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Pairwise distance table."
    )
    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Term frequency table."
    )
    title: str | None = Field(
        default=None, description="Optional title for the dendrogram."
    )
    show_on_init: bool = Field(
        default=False,
        description="Whether to display the plot immediately when the object is created.",
    )
    fig: plt.Figure | None = Field(
        default=None,
        description="Matplotlib figure containing the dendrogram plot.",
    )

    model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)

    def __init__(self, **data):
        """Initialize the Tree object."""
        super().__init__(**data)
        self.labels = list(self.labels)
        self.frequencies = (
            self.frequencies if self.frequencies is not None else pd.DataFrame()
        )
        if self.show_on_init:
            self.plot_tree()
            plt.show()

    def plot_tree(
        self,
        k: int = 2,
        method: Literal[
            "single",
            "complete",
            "average",
            "weighted",
            "centroid",
            "median",
            "ward",
        ] = "ward",
        top_n_words: int = 10,
        orientation: Literal["left", "right", "top", "bottom"] = "right",
        label_buffer: float = 0.0,
        outline_y_pad: float = 0.3,
        outline_axis_y_pad: float = 0.1,
        outline_tip_pad_ratio: float = 0.002,
        outline_root_pad_ratio: float = 0.1,
    ) -> plt.Figure:
        """Create the dendrogram figure without displaying it."""
        n_docs = self._ensure_plot_ready()
        k = self._sanitize_k(k, n_docs)
        self._validate_method(method)

        condensed = self._condense_distance_table()
        z = linkage(condensed, method=method)
        clusters = self._assign_clusters(z, k)

        threshold = self._compute_color_threshold(z, k, n_docs)
        palette = self._get_cluster_palette(k)
        color_func = self._dendrogram_color_func(z, clusters, palette)
        cluster_words = self._cluster_top_words(clusters, top_n_words)

        fig, ax1, ax2 = self._create_plot_axes()
        dendro = self._draw_dendrogram(ax1, z, orientation, threshold, color_func)
        self._finalize_dendrogram_axes(ax1, orientation)

        tip_x_pad, root_x_pad = self._compute_padding(
            ax1, orientation, outline_tip_pad_ratio, outline_root_pad_ratio
        )
        self._style_cluster_regions(
            ax1,
            dendro,
            clusters,
            palette,
            orientation,
            threshold,
            tip_x_pad,
            root_x_pad,
            outline_y_pad,
            outline_axis_y_pad,
        )

        self._label_axes(ax1, orientation, k)
        self._draw_cluster_word_summary(ax2, cluster_words, palette)

        self._apply_figure_layout(fig, orientation, label_buffer)
        self.fig = fig
        return fig

    def _ensure_plot_ready(self) -> int:
        if self.distance_table.empty:
            raise ValueError(
                "Distance table is required. Run compute_distances() first."
            )
        n_docs = len(self.labels)
        if n_docs == 0:
            raise ValueError("Labels are required to build the tree plot.")
        return n_docs

    def _sanitize_k(self, k: int, n_docs: int) -> int:
        return max(1, min(k, n_docs))

    def _validate_method(self, method: str) -> None:
        valid_methods = {
            "single",
            "complete",
            "average",
            "weighted",
            "centroid",
            "median",
            "ward",
        }
        if method not in valid_methods:
            raise ValueError(
                "method must be one of: " + ", ".join(sorted(valid_methods))
            )

    def _condense_distance_table(self) -> np.ndarray:
        return squareform(self.distance_table.to_numpy())

    def _assign_clusters(self, z: np.ndarray, k: int) -> np.ndarray:
        return cut_tree(z, n_clusters=[k]).reshape(-1) + 1

    def _compute_color_threshold(self, z: np.ndarray, k: int, n_docs: int) -> float:
        if k >= n_docs:
            return 0.0
        if k == 1:
            return float(z[-1, 2]) + 1e-12
        lower = float(z[-k, 2])
        upper = float(z[-k + 1, 2])
        return (lower + upper) / 2.0

    def _get_cluster_palette(self, k: int):
        return sns.color_palette("tab10", max(3, k))

    def _create_plot_axes(self):
        fig = plt.figure(figsize=(14, 10))
        fig.patch.set_facecolor("white")
        gs = fig.add_gridspec(2, 1, height_ratios=[3, 1], hspace=0.24)
        ax1 = fig.add_subplot(gs[0, 0])
        ax2 = fig.add_subplot(gs[1, 0])
        ax1.set_facecolor("white")
        ax2.set_facecolor("white")
        return fig, ax1, ax2

    def _draw_dendrogram(
        self,
        ax: plt.Axes,
        z: np.ndarray,
        orientation: Literal["left", "right", "top", "bottom"],
        threshold: float,
        color_func: Callable[[int], str],
    ):
        return dendrogram(
            z,
            labels=self.labels,
            orientation=orientation,
            color_threshold=threshold,
            link_color_func=color_func,
            ax=ax,
        )

    def _finalize_dendrogram_axes(
        self, ax: plt.Axes, orientation: Literal["left", "right", "top", "bottom"]
    ):
        if orientation in ["left", "right"]:
            ax.margins(x=0)
        else:
            ax.margins(y=0)
        self._apply_orientation_axis_style(ax, orientation)
        ax.grid(False)

    def _compute_padding(
        self,
        ax: plt.Axes,
        orientation: Literal["left", "right", "top", "bottom"],
        outline_tip_pad_ratio: float,
        outline_root_pad_ratio: float,
    ):
        if orientation in ["left", "right"]:
            x0, x1 = ax.get_xlim()
            return (
                abs(x1 - x0) * max(0.0, outline_tip_pad_ratio),
                abs(x1 - x0) * max(0.0, outline_root_pad_ratio),
            )
        y0, y1 = ax.get_ylim()
        return (
            abs(y1 - y0) * max(0.0, outline_tip_pad_ratio),
            abs(y1 - y0) * max(0.0, outline_root_pad_ratio),
        )

    def _label_axes(
        self,
        ax: plt.Axes,
        orientation: Literal["left", "right", "top", "bottom"],
        k: int,
    ):
        if not self.title:
            self.title = self._default_title_for_k(k)
        ax.set_title(self.title)
        if orientation in ["left", "right"]:
            ax.set_xlabel("Distance")
            ax.tick_params(axis="y", labelsize=7)
        else:
            ax.set_ylabel("Distance")
            ax.tick_params(axis="x", labelsize=7)

    def _default_title_for_k(self, k: int) -> str:
        return f"Dendrogram Cut into k={self._sanitize_k(k, len(self.labels))} Groups"

    def _draw_cluster_word_summary(
        self, ax: plt.Axes, cluster_words: dict[int, list[str]], palette
    ):
        ax.axis("off")
        x_positions = np.linspace(0.15, 0.85, len(cluster_words))
        for idx, cluster_id in enumerate(sorted(cluster_words)):
            words = cluster_words[cluster_id]
            color = palette[idx % len(palette)]
            x = x_positions[idx]

            ax.text(
                x,
                0.95,
                f"Cluster {idx + 1}",
                ha="center",
                va="top",
                fontsize=12,
                fontweight="bold",
                color=color,
            )
            for word_index, word in enumerate(words):
                ax.text(
                    x,
                    0.82 - word_index * 0.08,
                    sanitize_label_text(word),
                    ha="center",
                    va="top",
                    fontsize=10,
                    color=color,
                )

    def _apply_orientation_axis_style(
        self, ax: plt.Axes, orientation: Literal["left", "right", "top", "bottom"]
    ) -> None:
        """Apply orientation-specific tick styling so leaf labels stay visible."""
        if orientation == "right":
            # In scipy's 'right' orientation, leaves are on the left side.
            ax.yaxis.tick_left()
            ax.tick_params(axis="y", labelleft=True, labelright=False, pad=1)
            ax.spines["left"].set_visible(False)
        elif orientation == "left":
            # In scipy's 'left' orientation, leaves are on the right side.
            ax.yaxis.tick_right()
            ax.tick_params(axis="y", labelright=True, labelleft=False, pad=1)
            ax.spines["right"].set_visible(False)
        elif orientation == "top":
            # In scipy's 'top' orientation, leaves are shown at the bottom side.
            ax.xaxis.tick_bottom()
            ax.tick_params(axis="x", labelbottom=True, labeltop=False, pad=1)
            for label in ax.get_xticklabels():
                label.set_rotation(45)
                label.set_ha("right")
            ax.spines["bottom"].set_visible(False)
        else:
            # In scipy's 'bottom' orientation, leaves are shown at the top side.
            ax.xaxis.tick_top()
            ax.tick_params(axis="x", labeltop=True, labelbottom=False, pad=1)
            for label in ax.get_xticklabels():
                label.set_rotation(45)
                label.set_ha("left")
            ax.spines["top"].set_visible(False)

    def show(self) -> None:
        """Display the plot, creating it if necessary."""
        if self.fig is None:
            self.plot_tree()
        plt.show()

    def _apply_figure_layout(
        self,
        fig: plt.Figure,
        orientation: Literal["left", "right", "top", "bottom"] = "right",
        label_buffer: float = 0.0,
    ):
        """Apply a compact layout to a Matplotlib Figure.

        Args:
            fig (plt.Figure): The Matplotlib figure to adjust.
        """
        label_buffer = max(0.0, label_buffer)

        if orientation == "left":
            # In 'left' orientation, leaves/labels are on the right side.
            fig.subplots_adjust(
                left=0.04,
                right=max(0.55, 0.90 - label_buffer),
                top=0.94,
                bottom=0.12,
            )
        elif orientation == "right":
            # In 'right' orientation, leaves/labels are on the left side.
            fig.subplots_adjust(
                left=min(0.45, 0.10 + label_buffer),
                right=0.98,
                top=0.94,
                bottom=0.12,
            )
        elif orientation == "top":
            # In 'top' orientation, leaves/labels are on the bottom side.
            fig.subplots_adjust(
                left=0.06,
                right=0.98,
                top=0.90,
                bottom=min(0.45, 0.12 + label_buffer),
            )
        else:
            # In 'bottom' orientation, leaves/labels are on the top side.
            fig.subplots_adjust(
                left=0.06,
                right=0.98,
                top=max(0.55, 0.94 - label_buffer),
                bottom=0.18,
            )
        self._disable_canvas_bbox_inches(fig)

    def _disable_canvas_bbox_inches(self, fig: plt.Figure):
        """Disable bbox_inches overrides on the figure's canvas print method.

        Args:
            fig (plt.Figure): The Matplotlib figure whose canvas will be modified.
        """
        canvas = getattr(fig, "canvas", None)
        if canvas is None or not hasattr(canvas, "print_figure"):
            return

        original_print_figure = canvas.print_figure

        def _print_figure_no_bbox_inches(*args, **kwargs):
            """Override print_figure to ignore bbox_inches argument."""
            kwargs.pop("bbox_inches", None)
            return original_print_figure(*args, **kwargs)

        canvas.print_figure = _print_figure_no_bbox_inches

    def _cluster_top_words(
        self, clusters: np.ndarray, top_n: int = 10
    ) -> dict[int, list[str]]:
        """Identify the top N words for each cluster based on z-scores.

        Args:
            clusters (np.ndarray): Array of cluster assignments for each document.
            top_n (int): Number of top words to return for each cluster.

        Returns:
            dict[int, list[str]]: A dictionary mapping cluster IDs to their top N words.
        """
        z_scores = (self.frequencies - self.frequencies.mean()) / self.frequencies.std()
        z_scores = z_scores.fillna(0)

        cluster_top_words: dict[int, list[str]] = {}
        for cluster_id in np.unique(clusters):
            members = self.frequencies.index[clusters == cluster_id]
            if len(members) == 0:
                cluster_top_words[cluster_id] = []
                continue

            cluster_mean = z_scores.loc[members].mean(axis=0)
            cluster_top_words[cluster_id] = list(
                cluster_mean.sort_values(ascending=False).head(top_n).index
            )

        return cluster_top_words

    def _dendrogram_color_func(
        self,
        z: np.ndarray,
        clusters: np.ndarray,
        palette: list[tuple[float, float, float]],
    ) -> Callable[[int], str]:
        """Generate a color function for dendrogram links based on cluster membership.

        Args:
            z (np.ndarray): Linkage matrix from hierarchical clustering.
            clusters (np.ndarray): Array of cluster assignments for each document.
            palette (list[tuple[float, float, float]]): List of RGB colors for
            each cluster.

        Returns:
            Callable[[int], str]: A function that maps a link ID to a color.
        """
        n_leaves = z.shape[0] + 1
        link_to_cluster: dict[int, list[int]] = {}

        def leaf_clusters(node: int) -> list[int]:
            """Recursively find the cluster IDs of all leaves under a given node.

            Args:
                node (int): The node ID in the linkage matrix.

            Returns:
                list[int]: List of cluster IDs for the leaves under the node.
            """
            if node < n_leaves:
                return [int(clusters[node])]
            return link_to_cluster[node]

        for i, row in enumerate(z):
            left = int(row[0])
            right = int(row[1])
            members = leaf_clusters(left) + leaf_clusters(right)
            link_to_cluster[n_leaves + i] = members

        def color_func(link_id: int) -> str:
            """Determine the color for a given link in the dendrogram.

            Args:
                link_id (int): The link ID in the dendrogram.

            Returns:
                str: Hex color code for the link.
            """
            members = link_to_cluster.get(link_id, [])
            if members and all(m == members[0] for m in members):
                color = palette[(members[0] - 1) % len(palette)]
                return matplotlib.colors.to_hex(color)
            return "gray"

        return color_func

    def _style_cluster_regions(
        self,
        ax,
        dendro: dict,
        clusters: np.ndarray,
        palette: list[tuple[float, float, float]],
        orientation: Literal["left", "right", "top", "bottom"],
        threshold: float,
        tip_x_pad: float,
        root_x_pad: float,
        y_pad: float,
        axis_y_pad: float,
    ):
        """Draw colored rectangles around clusters in the dendrogram."""
        leaves = dendro.get("leaves", [])
        if not leaves:
            return

        ordered_clusters = [int(clusters[i]) for i in leaves]
        box_layout, tick_labels = self._compute_region_layout(
            ax, orientation, tip_x_pad, root_x_pad
        )
        self._color_cluster_tick_labels(tick_labels, ordered_clusters, palette)
        self._draw_cluster_region_rectangles(
            ax,
            ordered_clusters,
            palette,
            orientation,
            box_layout,
            y_pad,
        )
        self._apply_cluster_axis_padding(ax, orientation, axis_y_pad)

    def _compute_region_layout(
        self,
        ax,
        orientation: Literal["left", "right", "top", "bottom"],
        tip_x_pad: float,
        root_x_pad: float,
    ):
        """Compute axis layout for region rectangles and tick labels.

        Args:
            ax: Matplotlib Axes object for the dendrogram.
            orientation (Literal["left", "right", "top", "bottom"]): Plot orientation.
            tip_x_pad (float): Padding near dendrogram tips.
            root_x_pad (float): Padding near the dendrogram root.

        Returns:
            tuple[dict, list]: A dictionary with rectangle layout coordinates and a list of tick labels.
        """
        if orientation in ["left", "right"]:
            x0, x1 = ax.get_xlim()
            x_leaf, x_root = min(x0, x1), max(x0, x1)
            x_box_left = x_leaf + max(0.0, tip_x_pad)
            x_box_right = x_root - max(0.0, root_x_pad)
            if x_box_right <= x_box_left:
                x_box_left = x_leaf + (x_root - x_leaf) * 0.1
                x_box_right = x_root - (x_root - x_leaf) * 0.1
            return {
                "x_box_left": x_box_left,
                "x_box_right": x_box_right,
            }, ax.get_yticklabels()

        y0, y1 = ax.get_ylim()
        y_leaf, y_root = min(y0, y1), max(y0, y1)
        y_box_bottom = y_leaf + max(0.0, tip_x_pad)
        y_box_top = y_root - max(0.0, root_x_pad)
        if y_box_top <= y_box_bottom:
            y_box_bottom = y_leaf + (y_root - y_leaf) * 0.1
            y_box_top = y_root - (y_root - y_leaf) * 0.1
        return {
            "y_box_bottom": y_box_bottom,
            "y_box_top": y_box_top,
        }, ax.get_xticklabels()

    def _color_cluster_tick_labels(
        self,
        tick_labels,
        ordered_clusters: list[int],
        palette: list[tuple[float, float, float]],
    ):
        """Color tick labels according to cluster membership.

        Args:
            tick_labels: Tick label artists from a Matplotlib Axes.
            ordered_clusters (list[int]): Cluster IDs in leaf order.
            palette (list[tuple[float, float, float]]): RGB colors for each cluster.
        """
        for tick_index, label in enumerate(tick_labels):
            if tick_index >= len(ordered_clusters):
                break
            cid = ordered_clusters[tick_index]
            color = palette[(cid - 1) % len(palette)]
            label.set_color(color)

    def _draw_cluster_region_rectangles(
        self,
        ax,
        ordered_clusters: list[int],
        palette: list[tuple[float, float, float]],
        orientation: Literal["left", "right", "top", "bottom"],
        box_layout: dict,
        y_pad: float,
    ):
        """Draw rectangular region outlines around cluster groups.

        Args:
            ax: Matplotlib Axes object for the dendrogram.
            ordered_clusters (list[int]): Cluster IDs in leaf order.
            palette (list[tuple[float, float, float]]): RGB colors for each cluster.
            orientation (Literal["left", "right", "top", "bottom"]): Plot orientation.
            box_layout (dict): Coordinates for rectangle placement.
            y_pad (float): Padding between rectangle edges and leaves.
        """
        start = 0
        while start < len(ordered_clusters):
            cid = ordered_clusters[start]
            end = start
            while end + 1 < len(ordered_clusters) and ordered_clusters[end + 1] == cid:
                end += 1

            color = palette[(cid - 1) % len(palette)]
            rect = self._build_cluster_rectangle(
                orientation,
                box_layout,
                start,
                end,
                y_pad,
                color,
            )
            ax.add_patch(rect)
            start = end + 1

    def _build_cluster_rectangle(
        self,
        orientation: Literal["left", "right", "top", "bottom"],
        box_layout: dict,
        start: int,
        end: int,
        y_pad: float,
        color,
    ):
        """Build a Matplotlib Rectangle for a cluster region.

        Args:
            orientation (Literal["left", "right", "top", "bottom"]): Plot orientation.
            box_layout (dict): Coordinates for rectangle placement.
            start (int): Index of the first leaf in the current cluster block.
            end (int): Index of the last leaf in the current cluster block.
            y_pad (float): Padding between rectangle edges and leaves.
            color: Edge color for the rectangle.

        Returns:
            Rectangle: The configured Matplotlib rectangle patch.
        """
        if orientation in ["left", "right"]:
            y_bottom = 10 * start + y_pad
            y_top = 10 * (end + 1) - y_pad
            return Rectangle(
                (box_layout["x_box_left"], y_bottom),
                box_layout["x_box_right"] - box_layout["x_box_left"],
                y_top - y_bottom,
                fill=False,
                edgecolor=color,
                linestyle=(0, (6, 3)),
                linewidth=1.8,
                alpha=0.8,
            )

        x_left = 10 * start + y_pad
        x_right = 10 * (end + 1) - y_pad
        return Rectangle(
            (x_left, box_layout["y_box_bottom"]),
            x_right - x_left,
            box_layout["y_box_top"] - box_layout["y_box_bottom"],
            fill=False,
            edgecolor=color,
            linestyle=(0, (6, 3)),
            linewidth=1.8,
            alpha=0.8,
        )

    def _apply_cluster_axis_padding(
        self,
        ax,
        orientation: Literal["left", "right", "top", "bottom"],
        axis_y_pad: float,
    ):
        """Apply additional axis padding so cluster rectangles do not clip.

        Args:
            ax: Matplotlib Axes object for the dendrogram.
            orientation (Literal["left", "right", "top", "bottom"]): Plot orientation.
            axis_y_pad (float): Amount of padding to add to the axis limits.
        """
        if orientation in ["left", "right"]:
            y0, y1 = ax.get_ylim()
            if y0 < y1:
                ax.set_ylim(y0 - axis_y_pad, y1 + axis_y_pad)
            else:
                ax.set_ylim(y0 + axis_y_pad, y1 - axis_y_pad)
            return

        x0, x1 = ax.get_xlim()
        if x0 < x1:
            ax.set_xlim(x0 - axis_y_pad, x1 + axis_y_pad)
        else:
            ax.set_xlim(x0 + axis_y_pad, x1 - axis_y_pad)

distance_table: pd.DataFrame pydantic-field ¤

Pairwise distance table.

title: str | None = None pydantic-field ¤

Optional title for the dendrogram.

show_on_init: bool = False pydantic-field ¤

Whether to display the plot immediately when the object is created.

fig: plt.Figure | None = None pydantic-field ¤

Matplotlib figure containing the dendrogram plot.

__init__(**data) ¤

Initialize the Tree object.

Source code in lexos/cluster/seetrees/tree.py
def __init__(self, **data):
    """Initialize the Tree object."""
    super().__init__(**data)
    self.labels = list(self.labels)
    self.frequencies = (
        self.frequencies if self.frequencies is not None else pd.DataFrame()
    )
    if self.show_on_init:
        self.plot_tree()
        plt.show()

plot_tree(k: int = 2, method: Literal['single', 'complete', 'average', 'weighted', 'centroid', 'median', 'ward'] = 'ward', top_n_words: int = 10, orientation: Literal['left', 'right', 'top', 'bottom'] = 'right', label_buffer: float = 0.0, outline_y_pad: float = 0.3, outline_axis_y_pad: float = 0.1, outline_tip_pad_ratio: float = 0.002, outline_root_pad_ratio: float = 0.1) -> plt.Figure ¤

Create the dendrogram figure without displaying it.

Source code in lexos/cluster/seetrees/tree.py
def plot_tree(
    self,
    k: int = 2,
    method: Literal[
        "single",
        "complete",
        "average",
        "weighted",
        "centroid",
        "median",
        "ward",
    ] = "ward",
    top_n_words: int = 10,
    orientation: Literal["left", "right", "top", "bottom"] = "right",
    label_buffer: float = 0.0,
    outline_y_pad: float = 0.3,
    outline_axis_y_pad: float = 0.1,
    outline_tip_pad_ratio: float = 0.002,
    outline_root_pad_ratio: float = 0.1,
) -> plt.Figure:
    """Create the dendrogram figure without displaying it."""
    n_docs = self._ensure_plot_ready()
    k = self._sanitize_k(k, n_docs)
    self._validate_method(method)

    condensed = self._condense_distance_table()
    z = linkage(condensed, method=method)
    clusters = self._assign_clusters(z, k)

    threshold = self._compute_color_threshold(z, k, n_docs)
    palette = self._get_cluster_palette(k)
    color_func = self._dendrogram_color_func(z, clusters, palette)
    cluster_words = self._cluster_top_words(clusters, top_n_words)

    fig, ax1, ax2 = self._create_plot_axes()
    dendro = self._draw_dendrogram(ax1, z, orientation, threshold, color_func)
    self._finalize_dendrogram_axes(ax1, orientation)

    tip_x_pad, root_x_pad = self._compute_padding(
        ax1, orientation, outline_tip_pad_ratio, outline_root_pad_ratio
    )
    self._style_cluster_regions(
        ax1,
        dendro,
        clusters,
        palette,
        orientation,
        threshold,
        tip_x_pad,
        root_x_pad,
        outline_y_pad,
        outline_axis_y_pad,
    )

    self._label_axes(ax1, orientation, k)
    self._draw_cluster_word_summary(ax2, cluster_words, palette)

    self._apply_figure_layout(fig, orientation, label_buffer)
    self.fig = fig
    return fig

show() -> None ¤

Display the plot, creating it if necessary.

Source code in lexos/cluster/seetrees/tree.py
def show(self) -> None:
    """Display the plot, creating it if necessary."""
    if self.fig is None:
        self.plot_tree()
    plt.show()
members: true

The zscores Classes¤

ZscorePlot pydantic-model ¤

Bases: BaseModel

Render a ranked z-score bar chart for a target text.

The current SeeTrees class only creates DistinctiveFeaturePlot objects, since the plot can be quite cluttered. This class is provided as a basis for further development for users who prefer Matplotlib.

Config:

  • arbitrary_types_allowed: True
  • validate_assignment: True

Fields:

Source code in lexos/cluster/seetrees/zscores.py
class ZscorePlot(BaseModel):
    """Render a ranked z-score bar chart for a target text.

    The current SeeTrees class only creates DistinctiveFeaturePlot objects, since the plot can be quite cluttered. This class is provided as a basis for further development for users who prefer Matplotlib.
    """

    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Term frequency table."
    )
    target_text: str = Field(default="", description="Label for the target text.")
    top: int = Field(default=20, description="Number of features to display.")
    title: str | None = Field(default=None, description="Optional plot title.")
    positive_color: str = Field(
        default="#f6c1cc", description="Bar color for positive z-scores."
    )
    negative_color: str = Field(
        default="#b9dff1", description="Bar color for negative z-scores."
    )
    guide_color: str = Field(
        default="#c9ced6", description="Guide line color for non-zero SD lines."
    )
    zero_line_color: str = Field(
        default="red", description="Guide line color for the zero SD line."
    )
    fig: plt.Figure | None = Field(
        default=None, description="Matplotlib Figure object for plotting."
    )

    model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)

    def _z_scores(self) -> pd.DataFrame:
        z_scores = (self.frequencies - self.frequencies.mean()) / self.frequencies.std()
        return z_scores.fillna(0)

    def _top_series(self) -> pd.Series:
        z_scores = self._z_scores()
        text_profile = z_scores.loc[self.target_text]
        top_series = text_profile.abs().sort_values(ascending=False).head(self.top)
        selected = text_profile.loc[top_series.index]

        positive = selected[selected >= 0].sort_values(ascending=False)
        negative = selected[selected < 0].sort_values(ascending=True)
        return pd.concat([positive, negative])

    def plot(self) -> plt.Figure:
        """Render the z-score chart and return the figure."""
        ranked = self._top_series()
        features = ranked.index.tolist()
        values = ranked.to_numpy(dtype=float)
        colors = np.where(values >= 0, self.positive_color, self.negative_color)
        y_pos = np.arange(len(features))

        fig, ax = plt.subplots(figsize=(10, 8))
        fig.patch.set_facecolor("white")
        ax.set_facecolor("white")
        bars = ax.barh(y_pos, values, color=colors, edgecolor="white", height=0.95)
        ax.invert_yaxis()
        ax.set_yticks([])

        min_value = float(np.floor(min(values.min(), -1.0)))
        max_value = float(np.ceil(max(values.max(), 1.0)))

        min_tick = int(2 * np.floor(min_value / 2))
        max_tick = int(2 * np.ceil(max_value / 2))
        ax.set_xlim(min_tick, max_tick)
        ax.set_xticks(np.arange(min_tick, max_tick + 1, 2))

        for sd in range(int(np.floor(min_value)), int(np.ceil(max_value)) + 1):
            if sd == 0:
                continue
            ax.axvline(
                sd, color=self.guide_color, linestyle=":", linewidth=1.6, zorder=0
            )

        ax.axvline(
            0,
            color=self.zero_line_color,
            linestyle=":",
            linewidth=2.0,
            zorder=1,
        )

        # Keep feature labels close to the zero guide line for readability.
        label_offset = max(0.012 * (max_tick - min_tick), 0.04)
        for i, (feature, value, bar) in enumerate(zip(features, values, bars)):
            y_center = bar.get_y() + bar.get_height() / 2
            ax.text(
                value / 2,
                y_center,
                f"{value:.2f}",
                ha="center",
                va="center",
                fontsize=9,
                color="white",
                clip_on=False,
            )
            if value >= 0:
                ax.text(
                    -label_offset,
                    y_center,
                    sanitize_label_text(feature),
                    ha="right",
                    va="center",
                    fontsize=10,
                    color="#4d4d4d",
                )
            else:
                ax.text(
                    label_offset,
                    y_center,
                    sanitize_label_text(feature),
                    ha="left",
                    va="center",
                    fontsize=10,
                    color="#4d4d4d",
                )

        ax.tick_params(axis="x", colors="#4d4d4d", labelsize=11)
        ax.tick_params(axis="y", left=False, labelleft=False)
        ax.grid(False)
        for spine in ax.spines.values():
            spine.set_visible(False)

        if self.title:
            ax.set_title(self.title)
        else:
            ax.set_title(f"Top {self.top} z-scores in {self.target_text}")
        ax.set_xlabel("Standard deviation from the corpus mean")

        self.fig = fig
        return fig

    def show(self) -> None:
        """Display the z-score chart, creating it if needed."""
        if self.fig is None:
            self.plot()
        plt.show()

frequencies: pd.DataFrame pydantic-field ¤

Term frequency table.

target_text: str = '' pydantic-field ¤

Label for the target text.

top: int = 20 pydantic-field ¤

Number of features to display.

title: str | None = None pydantic-field ¤

Optional plot title.

positive_color: str = '#f6c1cc' pydantic-field ¤

Bar color for positive z-scores.

negative_color: str = '#b9dff1' pydantic-field ¤

Bar color for negative z-scores.

guide_color: str = '#c9ced6' pydantic-field ¤

Guide line color for non-zero SD lines.

zero_line_color: str = 'red' pydantic-field ¤

Guide line color for the zero SD line.

fig: plt.Figure | None = None pydantic-field ¤

Matplotlib Figure object for plotting.

plot() -> plt.Figure ¤

Render the z-score chart and return the figure.

Source code in lexos/cluster/seetrees/zscores.py
def plot(self) -> plt.Figure:
    """Render the z-score chart and return the figure."""
    ranked = self._top_series()
    features = ranked.index.tolist()
    values = ranked.to_numpy(dtype=float)
    colors = np.where(values >= 0, self.positive_color, self.negative_color)
    y_pos = np.arange(len(features))

    fig, ax = plt.subplots(figsize=(10, 8))
    fig.patch.set_facecolor("white")
    ax.set_facecolor("white")
    bars = ax.barh(y_pos, values, color=colors, edgecolor="white", height=0.95)
    ax.invert_yaxis()
    ax.set_yticks([])

    min_value = float(np.floor(min(values.min(), -1.0)))
    max_value = float(np.ceil(max(values.max(), 1.0)))

    min_tick = int(2 * np.floor(min_value / 2))
    max_tick = int(2 * np.ceil(max_value / 2))
    ax.set_xlim(min_tick, max_tick)
    ax.set_xticks(np.arange(min_tick, max_tick + 1, 2))

    for sd in range(int(np.floor(min_value)), int(np.ceil(max_value)) + 1):
        if sd == 0:
            continue
        ax.axvline(
            sd, color=self.guide_color, linestyle=":", linewidth=1.6, zorder=0
        )

    ax.axvline(
        0,
        color=self.zero_line_color,
        linestyle=":",
        linewidth=2.0,
        zorder=1,
    )

    # Keep feature labels close to the zero guide line for readability.
    label_offset = max(0.012 * (max_tick - min_tick), 0.04)
    for i, (feature, value, bar) in enumerate(zip(features, values, bars)):
        y_center = bar.get_y() + bar.get_height() / 2
        ax.text(
            value / 2,
            y_center,
            f"{value:.2f}",
            ha="center",
            va="center",
            fontsize=9,
            color="white",
            clip_on=False,
        )
        if value >= 0:
            ax.text(
                -label_offset,
                y_center,
                sanitize_label_text(feature),
                ha="right",
                va="center",
                fontsize=10,
                color="#4d4d4d",
            )
        else:
            ax.text(
                label_offset,
                y_center,
                sanitize_label_text(feature),
                ha="left",
                va="center",
                fontsize=10,
                color="#4d4d4d",
            )

    ax.tick_params(axis="x", colors="#4d4d4d", labelsize=11)
    ax.tick_params(axis="y", left=False, labelleft=False)
    ax.grid(False)
    for spine in ax.spines.values():
        spine.set_visible(False)

    if self.title:
        ax.set_title(self.title)
    else:
        ax.set_title(f"Top {self.top} z-scores in {self.target_text}")
    ax.set_xlabel("Standard deviation from the corpus mean")

    self.fig = fig
    return fig

show() -> None ¤

Display the z-score chart, creating it if needed.

Source code in lexos/cluster/seetrees/zscores.py
def show(self) -> None:
    """Display the z-score chart, creating it if needed."""
    if self.fig is None:
        self.plot()
    plt.show()
members: true

DistinctiveFeaturePlot pydantic-model ¤

Bases: BaseModel

Render a ranked z-score bar chart for a target text using Plotly.

Config:

  • arbitrary_types_allowed: True
  • validate_assignment: True

Fields:

Source code in lexos/cluster/seetrees/zscores.py
class DistinctiveFeaturePlot(BaseModel):
    """Render a ranked z-score bar chart for a target text using Plotly."""

    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Term frequency table."
    )
    target_text: str = Field(default="", description="Label for the target text.")
    top: int = Field(default=20, description="Number of features to display.")
    title: str | None = Field(default=None, description="Optional plot title.")
    positive_color: str = Field(
        default="#f6c1cc", description="Bar color for positive z-scores."
    )
    negative_color: str = Field(
        default="#b9dff1", description="Bar color for negative z-scores."
    )
    guide_color: str = Field(
        default="#c9ced6", description="Guide line color for non-zero SD lines."
    )
    zero_line_color: str = Field(
        default="red", description="Guide line color for the zero SD line."
    )
    width: int = Field(default=800, description="Figure width in pixels.")
    height: int = Field(default=600, description="Figure height in pixels.")
    fig: go.Figure | None = Field(
        default=None, description="Plotly Figure object for plotting."
    )

    model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)

    def _z_scores(self) -> pd.DataFrame:
        z_scores = (self.frequencies - self.frequencies.mean()) / self.frequencies.std()
        return z_scores.fillna(0)

    def _top_series(self) -> pd.Series:
        z_scores = self._z_scores()
        text_profile = z_scores.loc[self.target_text]
        top_series = text_profile.abs().sort_values(ascending=False).head(self.top)
        selected = text_profile.loc[top_series.index]

        positive = selected[selected >= 0].sort_values(ascending=False)
        negative = selected[selected < 0].sort_values(ascending=False)
        return pd.concat([positive, negative])

    def plot(self) -> go.Figure:
        """Render the z-score chart and return the Plotly figure."""
        ranked = self._top_series()
        features = ranked.index.tolist()
        values = ranked.to_numpy(dtype=float)
        colors = [
            self.positive_color if v >= 0 else self.negative_color for v in values
        ]

        max_abs = max(abs(values).max(), 1.0)
        min_value = float(np.floor(min(values.min(), -1.0)))
        max_value = float(np.ceil(max(values.max(), 1.0)))

        fig = go.Figure(
            data=go.Bar(
                x=values,
                y=features,
                orientation="h",
                marker_color=colors,
                text=[f"{v:.2f}" for v in values],
                textposition="inside",
                insidetextanchor="middle",
                hovertemplate="%{y}<br>Z-score: %{x:.2f}<extra></extra>",
            )
        )

        shapes = []
        for sd in np.arange(int(np.floor(min_value)), int(np.ceil(max_value)) + 1):
            if sd == 0:
                shapes.append(
                    dict(
                        type="line",
                        x0=0,
                        x1=0,
                        y0=-0.5,
                        y1=len(features) - 0.5,
                        line=dict(color=self.zero_line_color, dash="dot", width=2),
                    )
                )
            else:
                shapes.append(
                    dict(
                        type="line",
                        x0=sd,
                        x1=sd,
                        y0=-0.5,
                        y1=len(features) - 0.5,
                        line=dict(color=self.guide_color, dash="dot", width=1),
                    )
                )

        annotations = []
        small_offset = max_abs * 0.02
        for feature, value in zip(features, values):
            x = -small_offset if value >= 0 else small_offset
            anchor = "right" if value >= 0 else "left"
            annotations.append(
                dict(
                    x=x,
                    y=feature,
                    xanchor=anchor,
                    yanchor="middle",
                    text=sanitize_label_text(feature),
                    showarrow=False,
                    font=dict(color="#4d4d4d", size=10),
                )
            )

        fig.update_layout(
            title=self.title or f"Top {self.top} z-scores in {self.target_text}",
            xaxis=dict(
                title="Standard deviation from the corpus mean",
                tickmode="linear",
                dtick=2,
                zeroline=False,
            ),
            yaxis=dict(autorange="reversed", showticklabels=False),
            plot_bgcolor="white",
            shapes=shapes,
            annotations=annotations,
            margin=dict(l=140, r=40, t=80, b=40),
            width=self.width,
            height=self.height,
        )

        self.fig = fig
        return fig

    def show(self) -> None:
        """Display the Plotly z-score chart without the Plotly logo."""
        if self.fig is None:
            self.plot()
        self.fig.show(config={"displaylogo": False})

frequencies: pd.DataFrame pydantic-field ¤

Term frequency table.

target_text: str = '' pydantic-field ¤

Label for the target text.

top: int = 20 pydantic-field ¤

Number of features to display.

title: str | None = None pydantic-field ¤

Optional plot title.

positive_color: str = '#f6c1cc' pydantic-field ¤

Bar color for positive z-scores.

negative_color: str = '#b9dff1' pydantic-field ¤

Bar color for negative z-scores.

guide_color: str = '#c9ced6' pydantic-field ¤

Guide line color for non-zero SD lines.

zero_line_color: str = 'red' pydantic-field ¤

Guide line color for the zero SD line.

width: int = 800 pydantic-field ¤

Figure width in pixels.

height: int = 600 pydantic-field ¤

Figure height in pixels.

fig: go.Figure | None = None pydantic-field ¤

Plotly Figure object for plotting.

plot() -> go.Figure ¤

Render the z-score chart and return the Plotly figure.

Source code in lexos/cluster/seetrees/zscores.py
def plot(self) -> go.Figure:
    """Render the z-score chart and return the Plotly figure."""
    ranked = self._top_series()
    features = ranked.index.tolist()
    values = ranked.to_numpy(dtype=float)
    colors = [
        self.positive_color if v >= 0 else self.negative_color for v in values
    ]

    max_abs = max(abs(values).max(), 1.0)
    min_value = float(np.floor(min(values.min(), -1.0)))
    max_value = float(np.ceil(max(values.max(), 1.0)))

    fig = go.Figure(
        data=go.Bar(
            x=values,
            y=features,
            orientation="h",
            marker_color=colors,
            text=[f"{v:.2f}" for v in values],
            textposition="inside",
            insidetextanchor="middle",
            hovertemplate="%{y}<br>Z-score: %{x:.2f}<extra></extra>",
        )
    )

    shapes = []
    for sd in np.arange(int(np.floor(min_value)), int(np.ceil(max_value)) + 1):
        if sd == 0:
            shapes.append(
                dict(
                    type="line",
                    x0=0,
                    x1=0,
                    y0=-0.5,
                    y1=len(features) - 0.5,
                    line=dict(color=self.zero_line_color, dash="dot", width=2),
                )
            )
        else:
            shapes.append(
                dict(
                    type="line",
                    x0=sd,
                    x1=sd,
                    y0=-0.5,
                    y1=len(features) - 0.5,
                    line=dict(color=self.guide_color, dash="dot", width=1),
                )
            )

    annotations = []
    small_offset = max_abs * 0.02
    for feature, value in zip(features, values):
        x = -small_offset if value >= 0 else small_offset
        anchor = "right" if value >= 0 else "left"
        annotations.append(
            dict(
                x=x,
                y=feature,
                xanchor=anchor,
                yanchor="middle",
                text=sanitize_label_text(feature),
                showarrow=False,
                font=dict(color="#4d4d4d", size=10),
            )
        )

    fig.update_layout(
        title=self.title or f"Top {self.top} z-scores in {self.target_text}",
        xaxis=dict(
            title="Standard deviation from the corpus mean",
            tickmode="linear",
            dtick=2,
            zeroline=False,
        ),
        yaxis=dict(autorange="reversed", showticklabels=False),
        plot_bgcolor="white",
        shapes=shapes,
        annotations=annotations,
        margin=dict(l=140, r=40, t=80, b=40),
        width=self.width,
        height=self.height,
    )

    self.fig = fig
    return fig

show() -> None ¤

Display the Plotly z-score chart without the Plotly logo.

Source code in lexos/cluster/seetrees/zscores.py
def show(self) -> None:
    """Display the Plotly z-score chart without the Plotly logo."""
    if self.fig is None:
        self.plot()
    self.fig.show(config={"displaylogo": False})
members: true

FeatureSummary pydantic-model ¤

Bases: BaseModel

Encapsulate view_scores summary rendering.

Suggestions for improving the chart
  • switch selection from raw z-score cutoff to rank-based or feature-importance ranking
  • split positive and negative features into separate views instead of mixing both directions
  • use a dot/lollipop plot rather than bars to reduce visual clutter when many values tie
  • annotate only the most distinctive features and omit low-variance ties
  • collapse ties into grouped rank buckets when many values are identical

Config:

  • arbitrary_types_allowed: True
  • validate_assignment: True

Fields:

Source code in lexos/cluster/seetrees/zscores.py
class FeatureSummary(BaseModel):
    """Encapsulate view_scores summary rendering.

    Suggestions for improving the chart:
      - switch selection from raw z-score cutoff to rank-based or feature-importance ranking
      - split positive and negative features into separate views instead of mixing both directions
      - use a dot/lollipop plot rather than bars to reduce visual clutter when many values tie
      - annotate only the most distinctive features and omit low-variance ties
      - collapse ties into grouped rank buckets when many values are identical
    """

    frequencies: pd.DataFrame = Field(
        default_factory=pd.DataFrame, description="Term frequency table."
    )
    target_text: str = Field(default="", description="Label for the target text.")
    top: int = Field(default=20, description="Number of features to display.")

    model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)

    def _z_scores(self) -> pd.DataFrame:
        z_scores = (self.frequencies - self.frequencies.mean()) / self.frequencies.std()
        return z_scores.fillna(0)

    def _feature_order(self) -> list[str]:
        return (
            self.frequencies.mean()
            .sort_values(ascending=False)
            .index.tolist()[: self.top]
        )

    def to_dataframe(self) -> pd.DataFrame:
        """Convert the top distinctive features and their z-scores into a DataFrame.

        Returns:
            pd.DataFrame: DataFrame containing the top features and their z-scores.
        """
        z_scores = self._z_scores()
        text_profile = z_scores.loc[self.target_text]
        top_features = (
            text_profile.abs()
            .sort_values(ascending=False)
            .head(self.top)
            .index.tolist()
        )

        return pd.DataFrame(
            {
                "Feature": top_features,
                "Z-score": text_profile.loc[top_features].astype(float).to_numpy(),
            }
        )

    def render_bar_chart(self) -> plt.Figure:
        """Render a horizontal bar chart of the top distinctive features for the target text.

        Returns:
            plt.Figure: Matplotlib figure containing the bar chart.
        """
        plotter = ZscorePlot(
            frequencies=self.frequencies,
            target_text=self.target_text,
            top=self.top,
        )
        return plotter.plot()

frequencies: pd.DataFrame pydantic-field ¤

Term frequency table.

target_text: str = '' pydantic-field ¤

Label for the target text.

top: int = 20 pydantic-field ¤

Number of features to display.

to_dataframe() -> pd.DataFrame ¤

Convert the top distinctive features and their z-scores into a DataFrame.

Returns:

Type Description
DataFrame

pd.DataFrame: DataFrame containing the top features and their z-scores.

Source code in lexos/cluster/seetrees/zscores.py
def to_dataframe(self) -> pd.DataFrame:
    """Convert the top distinctive features and their z-scores into a DataFrame.

    Returns:
        pd.DataFrame: DataFrame containing the top features and their z-scores.
    """
    z_scores = self._z_scores()
    text_profile = z_scores.loc[self.target_text]
    top_features = (
        text_profile.abs()
        .sort_values(ascending=False)
        .head(self.top)
        .index.tolist()
    )

    return pd.DataFrame(
        {
            "Feature": top_features,
            "Z-score": text_profile.loc[top_features].astype(float).to_numpy(),
        }
    )

render_bar_chart() -> plt.Figure ¤

Render a horizontal bar chart of the top distinctive features for the target text.

Returns:

Type Description
Figure

plt.Figure: Matplotlib figure containing the bar chart.

Source code in lexos/cluster/seetrees/zscores.py
def render_bar_chart(self) -> plt.Figure:
    """Render a horizontal bar chart of the top distinctive features for the target text.

    Returns:
        plt.Figure: Matplotlib figure containing the bar chart.
    """
    plotter = ZscorePlot(
        frequencies=self.frequencies,
        target_text=self.target_text,
        top=self.top,
    )
    return plotter.plot()
members: true