WikiStar / Proccessor /section_classification.py
omerehrlich's picture
Add .gitignore
d1d57e3
Raw
History Blame Contribute Delete
18.5 kB
from typing import List, Dict, Any
import pandas as pd
from nltk.metrics.distance import edit_distance
import spacy
from spacy.tokens import Doc
import constants
import json
# Register custom extension for tracking original index
if not Doc.has_extension("original_index"):
Doc.set_extension("original_index", default=None)
def group_sections(df):
df = classify_sections_by_title(df, threshold=0.8)
df, suspected_splits = classify_revisions_by_content_modified(df, threshold=0.99)
grouped_df = group_titles_using_content(df)
section_splits_df = validate_section_splits(grouped_df, suspected_splits)
return grouped_df, section_splits_df
def identify_section_splits(df, length_threshold: int = 100):
if constants.GROUPED_IDX not in df.columns:
raise ValueError("The input file must contain 'grouped_idx'")
df[constants.CHANGED_CONTENT] = df[constants.CHANGED_CONTENT].fillna('').astype(str)
# Group by 'grouped_idx'
grouped = df.groupby('grouped_idx', sort=False)
revision_id_counts = df['Revision ID'].value_counts()
print("Identified potential section splits:")
print("Revision ID | Group Index | Length Change")
print("-" * 50)
for group_index, group in grouped:
previous_length = None
for idx in group.index: # Ensure iteration is in original order
current_length = len(df.at[idx, constants.CHANGED_CONTENT])
revision_id = df.at[idx, 'Revision ID']
if previous_length is not None:
length_difference = previous_length - current_length
# Check if the length difference exceeds the threshold
if length_difference > length_threshold and revision_id_counts[revision_id] > 1:
print(f"{revision_id:<12} | {group_index:<11} | {length_difference}")
# Update previous length
previous_length = current_length
def levenshtein_similarity(title_a, title_b):
"""
Calculate the Levenshtein similarity between two strings.
"""
title_a, title_b = str(title_a), str(title_b)
distance = edit_distance(title_a, title_b)
max_length = max(len(title_a), len(title_b))
return 1 - (distance / max_length) if max_length > 0 else 0
def classify_sections_by_title(df, threshold=0.8):
"""
Classify section titles based on Levenshtein similarity.
"""
section_sets = []
section_indices = []
for section in df['Section']:
max_avg_similarity = 0
max_set_index = None
for idx, section_set in enumerate(section_sets):
similarities = [levenshtein_similarity(section, existing_section) for existing_section in section_set]
avg_similarity = sum(similarities) / len(similarities)
if avg_similarity > max_avg_similarity:
max_avg_similarity = avg_similarity
max_set_index = idx
if max_avg_similarity > threshold and max_set_index is not None:
section_sets[max_set_index].add(section)
section_indices.append(max_set_index + 1)
else:
section_sets.append({section})
section_indices.append(len(section_sets))
df[constants.SECTION_IDX_TITLE] = section_indices
return df
def classify_revisions_by_content_modified(df, threshold=0.99, max_sentence_diff=4):
"""
Classify revisions based on content similarity using SciSpaCy,
collect suspected section-splits (sim>threshold AND diff>=max_sentence_diff),
but defer final split validation until after grouping is complete.
"""
import spacy
import pandas as pd
# Load model
nlp = spacy.load("en_core_sci_md")
nlp.max_length = 10000
# Prepare text
df[constants.CHANGED_CONTENT] = (
df[constants.CHANGED_CONTENT]
.fillna("")
.astype(str)
)
# Build spaCy docs
content_docs = []
for text in df[constants.CHANGED_CONTENT]:
if len(text) > nlp.max_length:
print(
f"Warning: Content length {len(text)} exceeds "
f"nlp.max_length. Truncating to {nlp.max_length} chars."
)
text = text[:nlp.max_length]
content_docs.append(nlp(text))
section_sets = [] # lists of Docs
section_indices = [] # final SECTION_IDX_CONTENT values
suspected_splits = [] # suspected split-event records (not yet validated)
# Iterate through each revision‐doc
for i, doc in enumerate(content_docs):
doc._.original_index = i
cur_sents = len(list(doc.sents))
# Track best small‐diff match
best_sim, best_idx, best_diff = 0.0, None, None
# Compare to each existing section's last doc
for sec_idx, sec in enumerate(section_sets):
last_doc = sec[-1]
last_sents = len(list(last_doc.sents))
sim = doc.similarity(last_doc)
diff = abs(cur_sents - last_sents)
# Record a SUSPECTED split event if sim high AND size-change large
if sim > threshold and diff >= max_sentence_diff:
suspected_splits.append({
"current_index": i,
"previous_index": last_doc._.original_index,
"similarity": sim,
"sentence_diff": diff,
})
# Only consider for grouping if size-change is small
if sim > best_sim and diff < max_sentence_diff:
best_sim, best_idx, best_diff = sim, sec_idx, diff
# Group-then-new logic (same as before)
if best_idx is not None and best_sim > threshold:
# high similarity + small diff ⇒ same section
section_sets[best_idx].append(doc)
section_indices.append(best_idx + 1)
else:
# otherwise ⇒ new section
section_sets.append([doc])
section_indices.append(len(section_sets))
# Write section indices back to DataFrame
df[constants.SECTION_IDX_CONTENT] = section_indices
return df, suspected_splits
def validate_section_splits(grouped_df, suspected_splits):
"""
Validate suspected section splits by checking if the items have different grouped indices.
Prevent duplicate splits by ensuring:
1. Each previous_idx is only used once (allows cascading X→Y→Z)
2. Each current_idx (revision) only appears once in the final splits (prevents A→X, B→X duplicates)
Args:
grouped_df: DataFrame with grouped_idx column
suspected_splits: List of suspected split records from classify_revisions_by_content_modified
Returns:
section_splits_df: DataFrame containing validated section splits
"""
import pandas as pd
if not suspected_splits:
return pd.DataFrame()
validated_splits = []
used_previous_indices = set() # Track previous_idx that have been used in confirmed splits
used_current_indices = set() # Track current_idx that have been used in confirmed splits
for split in suspected_splits:
current_idx = split["current_index"]
previous_idx = split["previous_index"]
# Skip if this previous_idx has already been used in a confirmed split
if previous_idx in used_previous_indices:
continue
# Skip if this current_idx has already been used in a confirmed split
# This prevents the same revision from appearing multiple times in splits
if current_idx in used_current_indices:
continue
# Get grouped indices for both items
current_grouped_idx = grouped_df.iloc[current_idx][constants.GROUPED_IDX]
previous_grouped_idx = grouped_df.iloc[previous_idx][constants.GROUPED_IDX]
# Validate split: different grouped indices = true section split
if current_grouped_idx != previous_grouped_idx:
validated_splits.append(split)
# Mark both indices as used
used_previous_indices.add(previous_idx)
used_current_indices.add(current_idx)
# Build enriched splits DataFrame (same format as original)
if validated_splits:
raw = pd.DataFrame(validated_splits)
out = grouped_df.iloc[raw["current_index"]].copy()
out["Previous Section"] = grouped_df.iloc[raw["previous_index"], grouped_df.columns.get_loc("Section")].values
out["Previous Revision ID"] = grouped_df.iloc[raw["previous_index"], grouped_df.columns.get_loc("Revision ID")].values
out["Previous Changed Content"] = grouped_df.iloc[raw["previous_index"], grouped_df.columns.get_loc(constants.CHANGED_CONTENT)].values
out["Similarity"] = raw["similarity"].values
out["Sentence Diff"] = raw["sentence_diff"].values
# Add grouped index information for debugging/analysis
out["Current Grouped Index"] = grouped_df.iloc[raw["current_index"], grouped_df.columns.get_loc(constants.GROUPED_IDX)].values
out["Previous Grouped Index"] = grouped_df.iloc[raw["previous_index"], grouped_df.columns.get_loc(constants.GROUPED_IDX)].values
section_splits_df = out
else:
section_splits_df = pd.DataFrame()
return section_splits_df
def classify_revisions_by_content(df, threshold=0.99, max_sentence_diff=4):
"""
Original function - kept for backward compatibility.
Classify revisions based on content similarity using SciSpaCy,
record any section-splits (sim>threshold AND diff>=max_sentence_diff),
but always prefer grouping into an existing section when diff<max_sentence_diff.
"""
import spacy
import pandas as pd
# Load model
nlp = spacy.load("en_core_sci_md")
nlp.max_length = 10000
# Prepare text
df[constants.CHANGED_CONTENT] = (
df[constants.CHANGED_CONTENT]
.fillna("")
.astype(str)
)
# Build spaCy docs
content_docs = []
for text in df[constants.CHANGED_CONTENT]:
if len(text) > nlp.max_length:
print(
f"Warning: Content length {len(text)} exceeds "
f"nlp.max_length. Truncating to {nlp.max_length} chars."
)
text = text[:nlp.max_length]
content_docs.append(nlp(text))
section_sets = [] # lists of Docs
section_indices = [] # final SECTION_IDX_CONTENT values
splits = [] # raw split-event records
# Iterate through each revision‐doc
for i, doc in enumerate(content_docs):
doc._.original_index = i
cur_sents = len(list(doc.sents))
# Track best small‐diff match
best_sim, best_idx, best_diff = 0.0, None, None
# Compare to each existing section's last doc
for sec_idx, sec in enumerate(section_sets):
last_doc = sec[-1]
last_sents = len(list(last_doc.sents))
sim = doc.similarity(last_doc)
diff = abs(cur_sents - last_sents)
# Record a split event if sim high AND size-change large
if sim > threshold and diff >= max_sentence_diff:
splits.append({
"current_index": i,
"previous_index": last_doc._.original_index,
"similarity": sim,
"sentence_diff": diff,
})
# Only consider for grouping if size-change is small
if sim > best_sim and diff < max_sentence_diff:
best_sim, best_idx, best_diff = sim, sec_idx, diff
# Group-then-new logic
if best_idx is not None and best_sim > threshold:
# high similarity + small diff ⇒ same section
section_sets[best_idx].append(doc)
section_indices.append(best_idx + 1)
else:
# otherwise ⇒ new section
section_sets.append([doc])
section_indices.append(len(section_sets))
# Write section indices back to DataFrame
df[constants.SECTION_IDX_CONTENT] = section_indices
# Build enriched splits DataFrame
if splits:
raw = pd.DataFrame(splits)
out = df.iloc[raw["current_index"]].copy()
out["Previous Section"] = df.iloc[raw["previous_index"], df.columns.get_loc("Section")].values
out["Previous Revision ID"] = df.iloc[raw["previous_index"], df.columns.get_loc("Revision ID")].values
out["Previous Changed Content"] = df.iloc[raw["previous_index"], df.columns.get_loc(constants.CHANGED_CONTENT)].values
out["Similarity"] = raw["similarity"].values
out["Sentence Diff"] = raw["sentence_diff"].values
section_splits_df = out
else:
section_splits_df = pd.DataFrame()
return df, section_splits_df
def group_titles_using_content(df):
filtered_df = df[df['Section'] != "(Top)"]
idx_dict = create_map_of_content_idx_to_relative_title_indexes(filtered_df)
grouped_indices = unite_indices(idx_dict)
title_to_group = {
title_index: group_index
for group_index, group_set in enumerate(grouped_indices, start=1) # Groups are 1-indexed
for title_index in group_set
}
# Step 5: Add the 'grouped_idx' column to the DataFrame
df[constants.GROUPED_IDX] = df[constants.SECTION_IDX_TITLE].map(title_to_group).fillna(0).astype(int)
return df
def create_map_of_content_idx_to_relative_title_indexes(filtered_df):
"""
Create a mapping from content index to title indexes.
:param filename_combined: str
Path to the input CSV file.
:return: dict
Dictionary where content index maps to a list of title indexes.
"""
return (
filtered_df.groupby(constants.SECTION_IDX_CONTENT)[constants.SECTION_IDX_TITLE]
.apply(list)
.to_dict()
)
def unite_indices(data):
"""
Unites overlapping indices from dictionary values into distinct sets.
:param data: dict
A dictionary where keys map to lists of indices.
:return: list
A list of sets with unified indices.
"""
# Convert the dictionary values into sets
sets = [set(value) for value in data.values()]
# Iteratively merge sets with overlaps
merged = []
while sets:
current = sets.pop(0)
overlap_found = False
for other_set in merged:
if current & other_set: # Check for overlap
other_set.update(current) # Merge overlapping sets
overlap_found = True
break
if not overlap_found:
merged.append(current) # Add as a new set
return merged
def check_section_name_changes(filename: str, threshold: float = 0.8) -> str:
"""
Parse the output CSV by section index and check for significant section name changes.
Log events where Levenshtein similarity is below the threshold.
"""
df = pd.read_csv(filename)
if 'Section from similarity' not in df.columns or 'Section' not in df.columns:
raise ValueError("The input file must contain 'Section' and 'Section from similarity' columns.")
df['Levenshtein Similarity from Previous'] = None
grouped = df.groupby('Section from similarity', sort=False)
for section_index, group in grouped:
previous_name = None
for idx in group.index: # Use original indices to ensure order
current_name = df.at[idx, 'Section']
if previous_name is not None:
distance = levenshtein_similarity(previous_name, current_name)
df.at[idx, 'Levenshtein Distance from Previous'] = distance
previous_name = current_name
output_filename = filename.replace(".csv", "_with_levenshtein_distance.csv")
df.to_csv(output_filename, index=False)
print(f"Levenshtein distance calculation completed. Results saved to {output_filename}.")
return output_filename
def generate_diff_json(df: pd.DataFrame, article_name: str, output_path: str = "diff_urls.json") -> List[Dict[str, Any]]:
"""
Generate a JSON file listing Wikipedia diff URLs and associated section title changes.
Each entry includes:
- 'url': diff link
- 'section_before': section title in previous revision
- 'section_after': section title in current revision
Grouping is done on both GROUPED_IDX and SECTION_IDX_CONTENT.
Args:
df (pd.DataFrame): DataFrame with revision data.
article_name (str): Wikipedia article title.
output_path (str): Path to save the output JSON file (default: diff_urls.json)
"""
required_cols = [
constants.GROUPED_IDX,
constants.SECTION_IDX_CONTENT,
constants.SECTION_IDX_TITLE,
'Revision ID',
'Section'
]
if not all(col in df.columns for col in required_cols):
raise ValueError(f"Missing one or more required columns: {required_cols}")
title_slug = article_name.replace(" ", "_")
diffs = []
# Sort and group by both GROUPED_IDX and SECTION_IDX_CONTENT
df_sorted = df.sort_values(by=[constants.GROUPED_IDX, constants.SECTION_IDX_CONTENT, 'Revision ID'])
grouped = df_sorted.groupby([constants.GROUPED_IDX, constants.SECTION_IDX_CONTENT], sort=False)
for (_, _), group_df in grouped:
group_df = group_df.reset_index(drop=True)
for i in range(1, len(group_df)):
current_title_idx = group_df.loc[i, constants.SECTION_IDX_TITLE]
prev_title_idx = group_df.loc[i - 1, constants.SECTION_IDX_TITLE]
if current_title_idx != prev_title_idx:
current_rev_id = group_df.loc[i, 'Revision ID']
prev_rev_id = group_df.loc[i - 1, 'Revision ID']
url = (
f"https://en.wikipedia.org/w/index.php"
f"?title={title_slug}"
f"&diff={current_rev_id}"
f"&oldid={prev_rev_id}"
)
diffs.append({
"url": url,
"section_before": group_df.loc[i - 1, 'Section'],
"section_after": group_df.loc[i, 'Section']
})
# Save JSON
with open(output_path, "w", encoding="utf-8") as f:
json.dump(diffs, f, indent=2, ensure_ascii=False)
print(f"Saved {len(diffs)} diff entries to {output_path}")
return diffs