Spaces:
Runtime error
Runtime error
Commit ·
54cd75a
1
Parent(s): 3e68fce
Add data loading pipeline (Person 1)
Browse filesUnified schema (Argument, Debate dataclasses), loaders for CMV offline
dataset, IBM Debater via HuggingFace, and a live Reddit scraper via PRAW.
Preprocessing module strips Reddit noise and filters short/deleted text.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .env.example +3 -0
- data/README.md +59 -0
- data/__init__.py +12 -0
- data/loaders/__init__.py +5 -0
- data/loaders/cmv.py +94 -0
- data/loaders/ibm.py +60 -0
- data/loaders/reddit.py +142 -0
- data/preprocessing/__init__.py +3 -0
- data/preprocessing/clean.py +71 -0
- data/schema.py +48 -0
.env.example
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
REDDIT_CLIENT_ID=your_client_id_here
|
| 2 |
+
REDDIT_CLIENT_SECRET=your_client_secret_here
|
| 3 |
+
REDDIT_USER_AGENT=nlp-project/1.0 by your_reddit_username
|
data/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Data module
|
| 2 |
+
|
| 3 |
+
Owns all data loading, cleaning, and the unified schema. Everything downstream imports from here.
|
| 4 |
+
|
| 5 |
+
## Unified schema
|
| 6 |
+
|
| 7 |
+
```python
|
| 8 |
+
from data import Argument, Debate
|
| 9 |
+
|
| 10 |
+
debate = Debate(id="abc", title="CMV: cats > dogs", source="cmv")
|
| 11 |
+
arg = Argument(id="1", text="Cats are independent.", arg_type="premise")
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
`arg_type` must be one of: `claim`, `counter_claim`, `premise`, `unknown`.
|
| 15 |
+
|
| 16 |
+
## Sources
|
| 17 |
+
|
| 18 |
+
### CMV (offline)
|
| 19 |
+
|
| 20 |
+
Download the Tan et al. 2016 dataset:
|
| 21 |
+
```bash
|
| 22 |
+
mkdir -p data/raw/cmv
|
| 23 |
+
curl -O https://chenhaot.com/data/cmv/cmv.tar.bz2
|
| 24 |
+
tar -xjf cmv.tar.bz2 -C data/raw/cmv/
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
Then:
|
| 28 |
+
```python
|
| 29 |
+
from data import load_cmv, clean_debates
|
| 30 |
+
debates = clean_debates(load_cmv("train"))
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
### IBM Debater (auto-download)
|
| 34 |
+
|
| 35 |
+
```python
|
| 36 |
+
from data import load_ibm
|
| 37 |
+
debates = load_ibm("train") # downloads via HuggingFace datasets on first run
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
### Live Reddit scraper
|
| 41 |
+
|
| 42 |
+
Add credentials to `.env` (copy from `.env.example`):
|
| 43 |
+
```
|
| 44 |
+
REDDIT_CLIENT_ID=your_id
|
| 45 |
+
REDDIT_CLIENT_SECRET=your_secret
|
| 46 |
+
REDDIT_USER_AGENT=nlp-project/1.0 by your_username
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
Then:
|
| 50 |
+
```python
|
| 51 |
+
from data import scrape_cmv, clean_debates
|
| 52 |
+
debates = clean_debates(scrape_cmv(limit=100, sort="top", time_filter="month"))
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
## Preprocessing
|
| 56 |
+
|
| 57 |
+
`clean_debates()` applies Reddit-specific noise removal (quoted text, URLs,
|
| 58 |
+
edit notes, user mentions) and drops arguments shorter than 5 tokens. Call it
|
| 59 |
+
on any list of `Debate` objects before passing them downstream.
|
data/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from data.schema import Argument, Debate
|
| 2 |
+
from data.loaders import load_cmv, load_ibm, scrape_cmv
|
| 3 |
+
from data.preprocessing import clean_debates
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"Argument",
|
| 7 |
+
"Debate",
|
| 8 |
+
"load_cmv",
|
| 9 |
+
"load_ibm",
|
| 10 |
+
"scrape_cmv",
|
| 11 |
+
"clean_debates",
|
| 12 |
+
]
|
data/loaders/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from data.loaders.cmv import load_cmv
|
| 2 |
+
from data.loaders.ibm import load_ibm
|
| 3 |
+
from data.loaders.reddit import scrape_cmv
|
| 4 |
+
|
| 5 |
+
__all__ = ["load_cmv", "load_ibm", "scrape_cmv"]
|
data/loaders/cmv.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Loader for the Change My View (CMV) dataset.
|
| 3 |
+
|
| 4 |
+
Source: Tan et al. 2016 "Winning Arguments" — r/changemyview threads
|
| 5 |
+
where the OP awards a delta (∆) to a reply that changed their view.
|
| 6 |
+
|
| 7 |
+
Expected raw file: data/raw/cmv/train.jsonl (and test.jsonl)
|
| 8 |
+
Each line is a JSON object:
|
| 9 |
+
{
|
| 10 |
+
"id": "<reddit_post_id>",
|
| 11 |
+
"title": "CMV: ...",
|
| 12 |
+
"selftext": "<OP body>",
|
| 13 |
+
"op_author": "<username>",
|
| 14 |
+
"comments": [
|
| 15 |
+
{
|
| 16 |
+
"id": "<comment_id>",
|
| 17 |
+
"body": "<text>",
|
| 18 |
+
"author": "<username>",
|
| 19 |
+
"score": <int>,
|
| 20 |
+
"parent_id": "<post_id or comment_id>",
|
| 21 |
+
"delta": <bool> # True if OP awarded a delta to this comment
|
| 22 |
+
}, ...
|
| 23 |
+
]
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
Download: https://chenhaot.com/data/cmv/cmv.tar.bz2
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
from typing import List
|
| 32 |
+
|
| 33 |
+
from data.schema import Argument, Debate
|
| 34 |
+
|
| 35 |
+
_CMV_RAW = Path("data/raw/cmv")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _comment_to_arg_type(comment: dict) -> str:
|
| 39 |
+
"""
|
| 40 |
+
Heuristic arg_type assignment:
|
| 41 |
+
- delta-awarded comments are counter_claims (they changed the OP's mind)
|
| 42 |
+
- top-level comments (parent == post) are claims
|
| 43 |
+
- nested replies are premises
|
| 44 |
+
"""
|
| 45 |
+
if comment.get("delta"):
|
| 46 |
+
return "counter_claim"
|
| 47 |
+
if str(comment.get("parent_id", "")).startswith("t3_"): # t3_ = link/post prefix
|
| 48 |
+
return "claim"
|
| 49 |
+
return "premise"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def load_cmv(split: str = "train") -> List[Debate]:
|
| 53 |
+
path = _CMV_RAW / f"{split}.jsonl"
|
| 54 |
+
if not path.exists():
|
| 55 |
+
raise FileNotFoundError(
|
| 56 |
+
f"CMV raw file not found at {path}.\n"
|
| 57 |
+
"Download from https://chenhaot.com/data/cmv/cmv.tar.bz2 "
|
| 58 |
+
"and extract into data/raw/cmv/"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
debates = []
|
| 62 |
+
with open(path) as f:
|
| 63 |
+
for line in f:
|
| 64 |
+
post = json.loads(line)
|
| 65 |
+
args = [
|
| 66 |
+
Argument(
|
| 67 |
+
id=post["id"],
|
| 68 |
+
text=post["selftext"],
|
| 69 |
+
arg_type="claim",
|
| 70 |
+
author=post.get("op_author"),
|
| 71 |
+
metadata={"title": post["title"]},
|
| 72 |
+
)
|
| 73 |
+
]
|
| 74 |
+
for c in post.get("comments", []):
|
| 75 |
+
args.append(
|
| 76 |
+
Argument(
|
| 77 |
+
id=c["id"],
|
| 78 |
+
text=c["body"],
|
| 79 |
+
arg_type=_comment_to_arg_type(c),
|
| 80 |
+
parent_id=c.get("parent_id"),
|
| 81 |
+
author=c.get("author"),
|
| 82 |
+
score=c.get("score"),
|
| 83 |
+
metadata={"delta": c.get("delta", False)},
|
| 84 |
+
)
|
| 85 |
+
)
|
| 86 |
+
debates.append(
|
| 87 |
+
Debate(
|
| 88 |
+
id=post["id"],
|
| 89 |
+
title=post["title"],
|
| 90 |
+
source="cmv",
|
| 91 |
+
arguments=args,
|
| 92 |
+
)
|
| 93 |
+
)
|
| 94 |
+
return debates
|
data/loaders/ibm.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Loader for IBM Debater — Argument Quality Ranking dataset.
|
| 3 |
+
|
| 4 |
+
HuggingFace: ibm/argument_quality_ranking_30k
|
| 5 |
+
~30k argument–topic pairs with human quality scores.
|
| 6 |
+
|
| 7 |
+
We map this to the unified schema:
|
| 8 |
+
- topic → Debate with a single claim (the motion)
|
| 9 |
+
- argument → Argument of type 'premise' attached to that claim
|
| 10 |
+
- WA (weighted average quality) stored in metadata
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from typing import List, Dict
|
| 14 |
+
|
| 15 |
+
from data.schema import Argument, Debate
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def load_ibm(split: str = "train") -> List[Debate]:
|
| 19 |
+
try:
|
| 20 |
+
from datasets import load_dataset
|
| 21 |
+
except ImportError:
|
| 22 |
+
raise ImportError("Install 'datasets': pip install datasets")
|
| 23 |
+
|
| 24 |
+
ds = load_dataset("ibm/argument_quality_ranking_30k", split=split, trust_remote_code=True)
|
| 25 |
+
|
| 26 |
+
# Group arguments by topic so each topic becomes one Debate
|
| 27 |
+
topics: Dict[str, List[dict]] = {}
|
| 28 |
+
for row in ds:
|
| 29 |
+
topics.setdefault(row["topic"], []).append(row)
|
| 30 |
+
|
| 31 |
+
debates = []
|
| 32 |
+
for topic, rows in topics.items():
|
| 33 |
+
topic_id = topic.replace(" ", "_")[:64]
|
| 34 |
+
claim = Argument(
|
| 35 |
+
id=f"{topic_id}__claim",
|
| 36 |
+
text=topic,
|
| 37 |
+
arg_type="claim",
|
| 38 |
+
)
|
| 39 |
+
premises = [
|
| 40 |
+
Argument(
|
| 41 |
+
id=f"{topic_id}__{i}",
|
| 42 |
+
text=row["argument"],
|
| 43 |
+
arg_type="premise",
|
| 44 |
+
parent_id=claim.id,
|
| 45 |
+
metadata={
|
| 46 |
+
"quality_score": row.get("WA"),
|
| 47 |
+
"stance": row.get("stance"),
|
| 48 |
+
},
|
| 49 |
+
)
|
| 50 |
+
for i, row in enumerate(rows)
|
| 51 |
+
]
|
| 52 |
+
debates.append(
|
| 53 |
+
Debate(
|
| 54 |
+
id=topic_id,
|
| 55 |
+
title=topic,
|
| 56 |
+
source="ibm",
|
| 57 |
+
arguments=[claim] + premises,
|
| 58 |
+
)
|
| 59 |
+
)
|
| 60 |
+
return debates
|
data/loaders/reddit.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Live Reddit scraper for r/changemyview threads via PRAW.
|
| 3 |
+
|
| 4 |
+
Requires a .env file with:
|
| 5 |
+
REDDIT_CLIENT_ID=...
|
| 6 |
+
REDDIT_CLIENT_SECRET=...
|
| 7 |
+
REDDIT_USER_AGENT=nlp-project/1.0 by <your_username>
|
| 8 |
+
|
| 9 |
+
Register a script app at https://www.reddit.com/prefs/apps
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
from typing import List, Optional
|
| 14 |
+
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
|
| 17 |
+
from data.schema import Argument, Debate
|
| 18 |
+
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _get_reddit():
|
| 23 |
+
try:
|
| 24 |
+
import praw
|
| 25 |
+
except ImportError:
|
| 26 |
+
raise ImportError("Install 'praw': pip install praw")
|
| 27 |
+
|
| 28 |
+
client_id = os.getenv("REDDIT_CLIENT_ID")
|
| 29 |
+
client_secret = os.getenv("REDDIT_CLIENT_SECRET")
|
| 30 |
+
user_agent = os.getenv("REDDIT_USER_AGENT", "nlp-project/1.0")
|
| 31 |
+
|
| 32 |
+
if not client_id or not client_secret:
|
| 33 |
+
raise EnvironmentError(
|
| 34 |
+
"Missing Reddit credentials. Set REDDIT_CLIENT_ID and "
|
| 35 |
+
"REDDIT_CLIENT_SECRET in your .env file."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
return praw.Reddit(
|
| 39 |
+
client_id=client_id,
|
| 40 |
+
client_secret=client_secret,
|
| 41 |
+
user_agent=user_agent,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _flatten_comments(comment, depth: int = 0) -> List[dict]:
|
| 46 |
+
"""Recursively flatten a comment tree into a list of dicts."""
|
| 47 |
+
results = []
|
| 48 |
+
try:
|
| 49 |
+
body = comment.body
|
| 50 |
+
except AttributeError:
|
| 51 |
+
return results # MoreComments object — skip
|
| 52 |
+
|
| 53 |
+
has_delta = "∆" in body or "!delta" in body.lower()
|
| 54 |
+
results.append({
|
| 55 |
+
"id": comment.id,
|
| 56 |
+
"body": body,
|
| 57 |
+
"author": str(comment.author) if comment.author else "[deleted]",
|
| 58 |
+
"score": comment.score,
|
| 59 |
+
"parent_id": comment.parent_id,
|
| 60 |
+
"delta": has_delta,
|
| 61 |
+
"depth": depth,
|
| 62 |
+
})
|
| 63 |
+
for reply in comment.replies:
|
| 64 |
+
results.extend(_flatten_comments(reply, depth + 1))
|
| 65 |
+
return results
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def scrape_cmv(
|
| 69 |
+
limit: int = 50,
|
| 70 |
+
sort: str = "top",
|
| 71 |
+
time_filter: str = "month",
|
| 72 |
+
min_comments: int = 5,
|
| 73 |
+
) -> List[Debate]:
|
| 74 |
+
"""
|
| 75 |
+
Scrape r/changemyview threads and return them as Debate objects.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
limit: number of posts to fetch (Reddit caps at 1000)
|
| 79 |
+
sort: 'top', 'hot', 'new', or 'controversial'
|
| 80 |
+
time_filter: 'day', 'week', 'month', 'year', 'all' (only for 'top')
|
| 81 |
+
min_comments: skip threads with fewer than this many comments
|
| 82 |
+
"""
|
| 83 |
+
reddit = _get_reddit()
|
| 84 |
+
subreddit = reddit.subreddit("changemyview")
|
| 85 |
+
|
| 86 |
+
if sort == "top":
|
| 87 |
+
posts = subreddit.top(time_filter=time_filter, limit=limit)
|
| 88 |
+
elif sort == "hot":
|
| 89 |
+
posts = subreddit.hot(limit=limit)
|
| 90 |
+
elif sort == "new":
|
| 91 |
+
posts = subreddit.new(limit=limit)
|
| 92 |
+
elif sort == "controversial":
|
| 93 |
+
posts = subreddit.controversial(time_filter=time_filter, limit=limit)
|
| 94 |
+
else:
|
| 95 |
+
raise ValueError(f"Unknown sort '{sort}'")
|
| 96 |
+
|
| 97 |
+
debates = []
|
| 98 |
+
for post in posts:
|
| 99 |
+
if post.num_comments < min_comments:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
post.comments.replace_more(limit=0) # skip MoreComments placeholders
|
| 103 |
+
flat_comments = []
|
| 104 |
+
for c in post.comments:
|
| 105 |
+
flat_comments.extend(_flatten_comments(c))
|
| 106 |
+
|
| 107 |
+
args = [
|
| 108 |
+
Argument(
|
| 109 |
+
id=post.id,
|
| 110 |
+
text=post.selftext,
|
| 111 |
+
arg_type="claim",
|
| 112 |
+
author=str(post.author) if post.author else "[deleted]",
|
| 113 |
+
score=post.score,
|
| 114 |
+
metadata={"title": post.title, "url": post.url},
|
| 115 |
+
)
|
| 116 |
+
]
|
| 117 |
+
for c in flat_comments:
|
| 118 |
+
arg_type = "counter_claim" if c["delta"] else (
|
| 119 |
+
"claim" if c["depth"] == 0 else "premise"
|
| 120 |
+
)
|
| 121 |
+
args.append(
|
| 122 |
+
Argument(
|
| 123 |
+
id=c["id"],
|
| 124 |
+
text=c["body"],
|
| 125 |
+
arg_type=arg_type,
|
| 126 |
+
parent_id=c["parent_id"],
|
| 127 |
+
author=c["author"],
|
| 128 |
+
score=c["score"],
|
| 129 |
+
metadata={"delta": c["delta"], "depth": c["depth"]},
|
| 130 |
+
)
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
debates.append(
|
| 134 |
+
Debate(
|
| 135 |
+
id=post.id,
|
| 136 |
+
title=post.title,
|
| 137 |
+
source="reddit",
|
| 138 |
+
arguments=args,
|
| 139 |
+
metadata={"subreddit": "changemyview", "sort": sort},
|
| 140 |
+
)
|
| 141 |
+
)
|
| 142 |
+
return debates
|
data/preprocessing/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from data.preprocessing.clean import clean_text, clean_debate, clean_debates, is_valid
|
| 2 |
+
|
| 3 |
+
__all__ = ["clean_text", "clean_debate", "clean_debates", "is_valid"]
|
data/preprocessing/clean.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Text cleaning utilities shared across all loaders.
|
| 3 |
+
|
| 4 |
+
Applied before tokenization — keeps text usable for both
|
| 5 |
+
classical NLP pipelines and transformer tokenizers.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
from typing import List
|
| 10 |
+
|
| 11 |
+
from data.schema import Argument, Debate
|
| 12 |
+
|
| 13 |
+
# Reddit-specific noise
|
| 14 |
+
_REDDIT_QUOTE = re.compile(r"^>.*$", re.MULTILINE)
|
| 15 |
+
_URL = re.compile(r"https?://\S+|www\.\S+")
|
| 16 |
+
_SUBREDDIT_MENTION = re.compile(r"r/\w+")
|
| 17 |
+
_USER_MENTION = re.compile(r"u/\w+")
|
| 18 |
+
_EDIT_NOTE = re.compile(r"\*?edit\*?:.*", re.IGNORECASE | re.DOTALL)
|
| 19 |
+
_WHITESPACE = re.compile(r"\s+")
|
| 20 |
+
|
| 21 |
+
# Deleted/removed placeholder strings
|
| 22 |
+
_DELETED = {"[deleted]", "[removed]", ""}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def clean_text(text: str) -> str:
|
| 26 |
+
text = _REDDIT_QUOTE.sub("", text)
|
| 27 |
+
text = _URL.sub("", text)
|
| 28 |
+
text = _SUBREDDIT_MENTION.sub("", text)
|
| 29 |
+
text = _USER_MENTION.sub("", text)
|
| 30 |
+
text = _EDIT_NOTE.sub("", text)
|
| 31 |
+
text = _WHITESPACE.sub(" ", text)
|
| 32 |
+
return text.strip()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def is_valid(text: str, min_tokens: int = 5) -> bool:
|
| 36 |
+
"""Return False for deleted posts or suspiciously short text."""
|
| 37 |
+
if text in _DELETED:
|
| 38 |
+
return False
|
| 39 |
+
return len(text.split()) >= min_tokens
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def clean_debate(debate: Debate, min_tokens: int = 5) -> Debate:
|
| 43 |
+
"""Return a new Debate with cleaned argument texts, dropping invalid ones."""
|
| 44 |
+
cleaned_args = []
|
| 45 |
+
for arg in debate.arguments:
|
| 46 |
+
text = clean_text(arg.text)
|
| 47 |
+
if is_valid(text, min_tokens):
|
| 48 |
+
cleaned_args.append(
|
| 49 |
+
Argument(
|
| 50 |
+
id=arg.id,
|
| 51 |
+
text=text,
|
| 52 |
+
arg_type=arg.arg_type,
|
| 53 |
+
parent_id=arg.parent_id,
|
| 54 |
+
author=arg.author,
|
| 55 |
+
score=arg.score,
|
| 56 |
+
metadata=arg.metadata,
|
| 57 |
+
)
|
| 58 |
+
)
|
| 59 |
+
return Debate(
|
| 60 |
+
id=debate.id,
|
| 61 |
+
title=debate.title,
|
| 62 |
+
source=debate.source,
|
| 63 |
+
arguments=cleaned_args,
|
| 64 |
+
metadata=debate.metadata,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def clean_debates(debates: List[Debate], min_tokens: int = 5) -> List[Debate]:
|
| 69 |
+
cleaned = [clean_debate(d, min_tokens) for d in debates]
|
| 70 |
+
# Drop debates that lost their root claim during cleaning
|
| 71 |
+
return [d for d in cleaned if d.root() is not None]
|
data/schema.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import List, Optional, Dict
|
| 3 |
+
|
| 4 |
+
ARG_TYPES = {"claim", "counter_claim", "premise", "unknown"}
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class Argument:
|
| 9 |
+
id: str
|
| 10 |
+
text: str
|
| 11 |
+
arg_type: str # one of ARG_TYPES
|
| 12 |
+
parent_id: Optional[str] = None
|
| 13 |
+
author: Optional[str] = None
|
| 14 |
+
score: Optional[int] = None # Reddit upvotes/downvotes
|
| 15 |
+
metadata: Dict = field(default_factory=dict)
|
| 16 |
+
|
| 17 |
+
def __post_init__(self):
|
| 18 |
+
if self.arg_type not in ARG_TYPES:
|
| 19 |
+
raise ValueError(f"arg_type must be one of {ARG_TYPES}, got '{self.arg_type}'")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class Debate:
|
| 24 |
+
id: str
|
| 25 |
+
title: str
|
| 26 |
+
source: str # 'cmv', 'ibm', 'reddit'
|
| 27 |
+
arguments: List[Argument] = field(default_factory=list)
|
| 28 |
+
metadata: Dict = field(default_factory=dict)
|
| 29 |
+
|
| 30 |
+
def root(self) -> Optional[Argument]:
|
| 31 |
+
"""The top-level claim (no parent)."""
|
| 32 |
+
roots = [a for a in self.arguments if a.parent_id is None]
|
| 33 |
+
return roots[0] if roots else None
|
| 34 |
+
|
| 35 |
+
def claims(self) -> List[Argument]:
|
| 36 |
+
return [a for a in self.arguments if a.arg_type == "claim"]
|
| 37 |
+
|
| 38 |
+
def counter_claims(self) -> List[Argument]:
|
| 39 |
+
return [a for a in self.arguments if a.arg_type == "counter_claim"]
|
| 40 |
+
|
| 41 |
+
def premises(self) -> List[Argument]:
|
| 42 |
+
return [a for a in self.arguments if a.arg_type == "premise"]
|
| 43 |
+
|
| 44 |
+
def replies_to(self, argument_id: str) -> List[Argument]:
|
| 45 |
+
return [a for a in self.arguments if a.parent_id == argument_id]
|
| 46 |
+
|
| 47 |
+
def __len__(self) -> int:
|
| 48 |
+
return len(self.arguments)
|