nicopbeard Claude Sonnet 4.6 commited on
Commit
54cd75a
·
1 Parent(s): 3e68fce

Add data loading pipeline (Person 1)

Browse files

Unified 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 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)