File size: 8,198 Bytes
2404dc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
import gradio as gr
import pandas as pd
import numpy as np
import re
from scipy.spatial.distance import pdist, squareform
from scipy.cluster.hierarchy import linkage
import plotly.figure_factory as ff
import plotly.graph_objects as go
import tempfile
import os

def extract_features(text):
    # Basic tokens
    tokens = re.findall(r'\b[a-zA-Z]+\b', text.lower())
    total_words = len(tokens)
    
    if total_words < 10:
        return None
        
    unique_words = len(set(tokens))
    
    # 1. Type-Token Ratio (Vocabulary Richness)
    ttr = unique_words / total_words
    
    # 2. Average Word Length
    avg_word_len = np.mean([len(w) for w in tokens])
    
    # 3. Sentence Length Metrics
    sentences = re.split(r'[.!?]+', text)
    sentences = [s.strip() for s in sentences if s.strip()]
    sent_lengths = [len(re.findall(r'\b[a-zA-Z]+\b', s)) for s in sentences]
    avg_sent_len = np.mean(sent_lengths) if sent_lengths else 0
    std_sent_len = np.std(sent_lengths) if sent_lengths else 0
    
    # 4. Punctuation frequencies
    commas = len(re.findall(r',', text)) / total_words
    semicolons = len(re.findall(r';', text)) / total_words
    exclamations = len(re.findall(r'!', text)) / total_words
    questions = len(re.findall(r'\?', text)) / total_words
    
    return {
        "Vocabulary Richness (TTR)": ttr,
        "Average Word Length": avg_word_len,
        "Average Sentence Length": avg_sent_len,
        "Sentence Length Variation (STD)": std_sent_len,
        "Commas Frequency": commas,
        "Semicolons Frequency": semicolons,
        "Exclamation Frequency": exclamations,
        "Question Frequency": questions
    }

def run_stylometry(files, chosen_features):
    if not files or len(files) < 3:
        return "Please upload at least 3 distinct text files (TXT format) to perform comparative stylometry.", None, None, None
        
    documents = {}
    doc_features = {}
    
    for file_obj in files:
        # Extract filename as label
        label = os.path.splitext(os.path.basename(file_obj.name))[0]
        try:
            with open(file_obj.name, "r", encoding="utf-8", errors="ignore") as f:
                text = f.read()
            feats = extract_features(text)
            if feats:
                documents[label] = text
                doc_features[label] = feats
        except Exception as e:
            return f"Error reading file '{label}': {str(e)}", None, None, None
            
    if len(doc_features) < 3:
        return "At least 3 files must have valid text content of at least 10 words.", None, None, None
        
    df_features = pd.DataFrame(doc_features).transpose()
    
    # Subset by chosen features
    if not chosen_features:
        chosen_features = list(df_features.columns)
        
    df_sub = df_features[chosen_features]
    
    # Standardize features (Z-Score)
    df_norm = (df_sub - df_sub.mean()) / df_sub.std()
    # Replace possible NaNs from zero standard deviation
    df_norm = df_norm.fillna(0)
    
    labels = list(df_norm.index)
    
    # Calculate Distance Matrix (Euclidean)
    distances = pdist(df_norm.values, metric='euclidean')
    dist_matrix = squareform(distances)
    
    # Max similarity corresponds to 0 distance
    # Convert distance to a similarity score between 0 and 100%
    max_d = np.max(dist_matrix) if np.max(dist_matrix) > 0 else 1.0
    sim_matrix = (1.0 - (dist_matrix / max_d)) * 100
    
    # 1. Similarity Heatmap Plotly
    fig_heatmap = go.Figure(data=go.Heatmap(
        z=sim_matrix,
        x=labels,
        y=labels,
        colorscale='Hot',
        text=[[f"{val:.1f}%" for val in row] for row in sim_matrix],
        texttemplate="%{text}",
        hoverinfo='z'
    ))
    
    fig_heatmap.update_layout(
        title="Stylometric Style Similarity Heatmap (%)",
        paper_bgcolor='#16100c',
        plot_bgcolor='#16100c',
        font_color='#f4eee6',
        xaxis=dict(gridcolor='rgba(255,255,255,0.05)'),
        yaxis=dict(gridcolor='rgba(255,255,255,0.05)'),
        margin=dict(l=40, r=40, t=50, b=40)
    )
    
    # 2. Hierarchical Cluster Dendrogram
    try:
        Z = linkage(distances, 'ward')
        fig_dendro = ff.create_dendrogram(df_norm.values, orientation='left', labels=labels, linkagefun=lambda x: Z)
        fig_dendro.update_layout(
            title="Hierarchical Stylistic Cluster Dendrogram",
            paper_bgcolor='#16100c',
            plot_bgcolor='#16100c',
            font_color='#f4eee6',
            xaxis=dict(showgrid=True, gridcolor='rgba(255,255,255,0.05)', title="Distance (Ward threshold)"),
            yaxis=dict(gridcolor='rgba(255,255,255,0.05)'),
            margin=dict(l=80, r=40, t=50, b=40)
        )
        # Match colors to style system
        for trace in fig_dendro.data:
            if 'color' in trace:
                trace.line.color = '#ff7043'
    except Exception as e:
        # Fallback to simple placeholder scatter if dendrogram linkage fails (e.g. from numerical identical elements)
        fig_dendro = go.Figure()
        fig_dendro.add_annotation(text=f"Hierarchical linkage omitted: {str(e)}", showarrow=False, font=dict(size=14))
        fig_dendro.update_layout(paper_bgcolor='#16100c', font_color='#f4eee6')

    # Prep output tables
    df_features_out = df_features.round(4).reset_index().rename(columns={"index": "Document Label"})
    
    # Save CSV
    out_csv = tempfile.mktemp(suffix=".csv")
    df_features_out.to_csv(out_csv, index=False)
    
    return "", fig_heatmap, fig_dendro, df_features_out, gr.update(value=out_csv, visible=True)

theme = gr.themes.Default(
    primary_hue="orange",
    neutral_hue="stone"
).set(
    body_background_fill="#0d0907",
    body_text_color="#c4bbae",
    block_background_fill="#16100c",
    block_border_width="1px",
    block_label_text_color="#f4eee6"
)

all_features = [
    "Vocabulary Richness (TTR)",
    "Average Word Length",
    "Average Sentence Length",
    "Sentence Length Variation (STD)",
    "Commas Frequency",
    "Semicolons Frequency",
    "Exclamation Frequency",
    "Question Frequency"
]

with gr.Blocks(theme=theme, title="Comparative Stylometry Analyzer") as demo:
    gr.Markdown(
        """
        # 🖋️ Comparative Stylometry & Authorship Analyzer
        ### Extract, analyze, and map the quantitative writing styles of multiple documents. Perfect for authorship debates, forensic linguistics, and distant reading comparisons.
        """
    )
    
    error_msg = gr.Markdown("", visible=False)
    
    with gr.Row():
        with gr.Column(scale=1):
            file_objs = gr.File(
                label="Upload Text Files (Select at least 3 TXT files)", 
                file_types=[".txt"], 
                file_count="multiple"
            )
            
            chosen_features = gr.CheckboxGroup(
                choices=all_features,
                value=all_features[:5],
                label="Stylometric Features to Compare",
                info="Toggle features to customize the mathematical fingerprint vector."
            )
            
            btn = gr.Button("Calculate Writing Footprints", variant="primary")
            
        with gr.Column(scale=2):
            with gr.Tabs():
                with gr.TabItem("Style Similarity Matrix"):
                    plot_heatmap = gr.Plot()
                with gr.TabItem("Clustering Dendrogram"):
                    plot_dendro = gr.Plot()
                with gr.TabItem("Extracted Stylometrics Table"):
                    table_features = gr.Dataframe()
                    download_btn = gr.File(label="Download Full Stylometrics CSV", visible=False)

    def process(files, features):
        err, heatmap, dendro, table, csv_path = run_stylometry(files, features)
        if err:
            return gr.update(value=err, visible=True), None, None, None, gr.update(visible=False)
        return gr.update(visible=False), heatmap, dendro, table, csv_path

    btn.click(
        process,
        inputs=[file_objs, chosen_features],
        outputs=[error_msg, plot_heatmap, plot_dendro, table_features, download_btn]
    )

if __name__ == "__main__":
    demo.launch()