Spaces:
Paused
Paused
| 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() | |