dstpr 0.2.2__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dstpr-0.2.2 → dstpr-0.2.4}/PKG-INFO +3 -31
- {dstpr-0.2.2 → dstpr-0.2.4}/README.md +2 -30
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/cleaners/post_process.py +9 -7
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/cleaners/pre_process.py +11 -11
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/pipeline.py +6 -11
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/profiles.py +22 -9
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/rankers/hybrid.py +50 -46
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/segmenters/section_split.py +26 -20
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr.egg-info/PKG-INFO +3 -31
- {dstpr-0.2.2 → dstpr-0.2.4}/pyproject.toml +1 -1
- {dstpr-0.2.2 → dstpr-0.2.4}/LICENSE +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/__init__.py +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/cleaners/__init__.py +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/rankers/__init__.py +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/segmenters/__init__.py +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/utils/cli.py +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr/utils/setup.py +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr.egg-info/SOURCES.txt +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr.egg-info/dependency_links.txt +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr.egg-info/entry_points.txt +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr.egg-info/requires.txt +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/dstpr.egg-info/top_level.txt +0 -0
- {dstpr-0.2.2 → dstpr-0.2.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dstpr
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: A high-throughput, domain-specific text preprocessing cascading pipeline filter to rank core sentences in texts and filter away boilerplate.
|
|
5
5
|
Author-email: Stephen Meisenbacher <sjmeis@gtgd.com>
|
|
6
6
|
Maintainer-email: Stephen Meisenbacher <sjmeis@gtgd.com>
|
|
@@ -76,7 +76,7 @@ This is the recommended approach for large-scale data pipelines.
|
|
|
76
76
|
```python
|
|
77
77
|
from dstpr import ParallelPreprocessingPipeline, JOB_POSTING_PROFILE
|
|
78
78
|
|
|
79
|
-
pipeline = ParallelPreprocessingPipeline(profile=JOB_POSTING_PROFILE
|
|
79
|
+
pipeline = ParallelPreprocessingPipeline(profile=JOB_POSTING_PROFILE)
|
|
80
80
|
|
|
81
81
|
raw_documents = [
|
|
82
82
|
"DOC 1",
|
|
@@ -84,41 +84,13 @@ raw_documents = [
|
|
|
84
84
|
"..."
|
|
85
85
|
]
|
|
86
86
|
|
|
87
|
-
cleaned_documents = pipeline.process(raw_documents,
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
### Advanced Usage
|
|
91
|
-
|
|
92
|
-
If you want to integrate specific pipeline layers directly into an existing workflow, or tweak the internal parameters, you can import individual modules manually:
|
|
93
|
-
|
|
94
|
-
```python
|
|
95
|
-
from dstpr.cleaners import clean_and_split_chunks
|
|
96
|
-
from dstpr.segmenters import SemanticSectionRouter
|
|
97
|
-
from dstpr.rankers import HybridRanker
|
|
98
|
-
from sentence_transformers import SentenceTransformer
|
|
99
|
-
|
|
100
|
-
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
101
|
-
|
|
102
|
-
text = "Job posting text goes here!"
|
|
103
|
-
|
|
104
|
-
# Clean and segment sentences
|
|
105
|
-
sentences = clean_and_split_chunks(text)
|
|
106
|
-
|
|
107
|
-
# Route by context
|
|
108
|
-
router = SemanticSectionRouter(model=embedding_model)
|
|
109
|
-
buckets = router.route_sentences(sentences)
|
|
110
|
-
target_sentences = buckets['CORE'] + buckets['REQUIREMENTS']
|
|
111
|
-
|
|
112
|
-
# Grade and sort text features
|
|
113
|
-
ranker = HybridRanker()
|
|
114
|
-
final = ranker.rank_and_filter(target_sentences, bi_cutoff_pct=0.25, final_threshold=0.40)
|
|
87
|
+
cleaned_documents = pipeline.process(raw_documents, threshold=0.25)
|
|
115
88
|
```
|
|
116
89
|
|
|
117
90
|
### Parameter Tuning
|
|
118
91
|
|
|
119
92
|
To adjust the trade-off between strict filtering and execution speed, consider tweaking these variables in `ParallelPreprocessingPipeline`:
|
|
120
93
|
- `threshold` (Default: `0.4`): Controls how aggressively sentences are discarded. Raising this value toward `0.6`, for example, ensures only stronger matching sentences are passed through. Lowering it toward `0.3`, on the other hand, acts as a wider net.
|
|
121
|
-
- `batch_size` (Default: `256`): This depends on your hardware! Adjust for best performance.
|
|
122
94
|
|
|
123
95
|
## Creating Custom Domain Profiles (via the provided wizard)
|
|
124
96
|
|
|
@@ -36,7 +36,7 @@ This is the recommended approach for large-scale data pipelines.
|
|
|
36
36
|
```python
|
|
37
37
|
from dstpr import ParallelPreprocessingPipeline, JOB_POSTING_PROFILE
|
|
38
38
|
|
|
39
|
-
pipeline = ParallelPreprocessingPipeline(profile=JOB_POSTING_PROFILE
|
|
39
|
+
pipeline = ParallelPreprocessingPipeline(profile=JOB_POSTING_PROFILE)
|
|
40
40
|
|
|
41
41
|
raw_documents = [
|
|
42
42
|
"DOC 1",
|
|
@@ -44,41 +44,13 @@ raw_documents = [
|
|
|
44
44
|
"..."
|
|
45
45
|
]
|
|
46
46
|
|
|
47
|
-
cleaned_documents = pipeline.process(raw_documents,
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
### Advanced Usage
|
|
51
|
-
|
|
52
|
-
If you want to integrate specific pipeline layers directly into an existing workflow, or tweak the internal parameters, you can import individual modules manually:
|
|
53
|
-
|
|
54
|
-
```python
|
|
55
|
-
from dstpr.cleaners import clean_and_split_chunks
|
|
56
|
-
from dstpr.segmenters import SemanticSectionRouter
|
|
57
|
-
from dstpr.rankers import HybridRanker
|
|
58
|
-
from sentence_transformers import SentenceTransformer
|
|
59
|
-
|
|
60
|
-
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
61
|
-
|
|
62
|
-
text = "Job posting text goes here!"
|
|
63
|
-
|
|
64
|
-
# Clean and segment sentences
|
|
65
|
-
sentences = clean_and_split_chunks(text)
|
|
66
|
-
|
|
67
|
-
# Route by context
|
|
68
|
-
router = SemanticSectionRouter(model=embedding_model)
|
|
69
|
-
buckets = router.route_sentences(sentences)
|
|
70
|
-
target_sentences = buckets['CORE'] + buckets['REQUIREMENTS']
|
|
71
|
-
|
|
72
|
-
# Grade and sort text features
|
|
73
|
-
ranker = HybridRanker()
|
|
74
|
-
final = ranker.rank_and_filter(target_sentences, bi_cutoff_pct=0.25, final_threshold=0.40)
|
|
47
|
+
cleaned_documents = pipeline.process(raw_documents, threshold=0.25)
|
|
75
48
|
```
|
|
76
49
|
|
|
77
50
|
### Parameter Tuning
|
|
78
51
|
|
|
79
52
|
To adjust the trade-off between strict filtering and execution speed, consider tweaking these variables in `ParallelPreprocessingPipeline`:
|
|
80
53
|
- `threshold` (Default: `0.4`): Controls how aggressively sentences are discarded. Raising this value toward `0.6`, for example, ensures only stronger matching sentences are passed through. Lowering it toward `0.3`, on the other hand, acts as a wider net.
|
|
81
|
-
- `batch_size` (Default: `256`): This depends on your hardware! Adjust for best performance.
|
|
82
54
|
|
|
83
55
|
## Creating Custom Domain Profiles (via the provided wizard)
|
|
84
56
|
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from dstpr.profiles import DomainProfile
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
LEADING_JUNK_RE = re.compile(r'^[\s\-\d\.\,\*•■\:\>]+')
|
|
5
|
+
TRAILING_NUM_RE = re.compile(r'\b\d+[\.,]\d+$|\b\d+$')
|
|
6
|
+
MULTI_DOT_SPACE_RE = re.compile(r'\s*\.+\s*')
|
|
7
|
+
|
|
8
|
+
def sanitize_outputs(sentences: list[str], profile: DomainProfile, min_length: int = 20) -> str:
|
|
5
9
|
if not sentences:
|
|
6
10
|
return ""
|
|
7
11
|
|
|
8
12
|
seen = set()
|
|
9
13
|
cleaned_pool = []
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
junk_matcher = re.compile(profile.junk_line_regex, re.IGNORECASE) if profile.junk_line_regex else None
|
|
15
|
+
junk_matcher = profile._junk_line_re
|
|
13
16
|
|
|
14
17
|
for sentence in sentences:
|
|
15
18
|
sent = sentence.strip()
|
|
@@ -21,18 +24,18 @@ def sanitize_outputs(sentences: list[str], profile: DomainProfile, min_length: i
|
|
|
21
24
|
if junk_matcher and junk_matcher.search(sent):
|
|
22
25
|
continue
|
|
23
26
|
|
|
24
|
-
sent =
|
|
27
|
+
sent = LEADING_JUNK_RE.sub('', sent)
|
|
25
28
|
|
|
26
29
|
if sent:
|
|
27
30
|
sent = sent[0].upper() + sent[1:]
|
|
28
|
-
sent =
|
|
31
|
+
sent = MULTI_DOT_SPACE_RE.sub('. ', sent).strip()
|
|
29
32
|
if not sent.endswith('.'):
|
|
30
33
|
sent += '.'
|
|
31
34
|
|
|
32
35
|
if len(sent) < min_length:
|
|
33
36
|
continue
|
|
34
37
|
|
|
35
|
-
if
|
|
38
|
+
if TRAILING_NUM_RE.search(sent):
|
|
36
39
|
continue
|
|
37
40
|
|
|
38
41
|
cleaned_pool.append(sent)
|
|
@@ -41,6 +44,5 @@ def sanitize_outputs(sentences: list[str], profile: DomainProfile, min_length: i
|
|
|
41
44
|
final_text = re.sub(r'\s+', ' ', final_text)
|
|
42
45
|
final_text = re.sub(r'\s*\.\s*', '. ', final_text)
|
|
43
46
|
final_text = re.sub(r'\.+', '.', final_text)
|
|
44
|
-
final_text = re.sub(r'\.+', '.', final_text)
|
|
45
47
|
|
|
46
48
|
return final_text.strip()
|
|
@@ -2,16 +2,19 @@ import re
|
|
|
2
2
|
import nltk
|
|
3
3
|
from dstpr.profiles import DomainProfile
|
|
4
4
|
|
|
5
|
+
INLINE_SPLIT_RE = re.compile(r'(?:[\•\·\■\*]|\(\d+\)|\b\d+\)|\;\s+(?=[A-Z]))')
|
|
6
|
+
|
|
5
7
|
def clean_and_split_chunks(text: str, profile: DomainProfile, min_length: int = 15) -> list[str]:
|
|
6
8
|
if not text or not isinstance(text, str):
|
|
7
9
|
return []
|
|
8
10
|
|
|
9
|
-
if profile.
|
|
10
|
-
text =
|
|
11
|
-
lines = [line.strip() for line in text.split("\n") if line.strip()]
|
|
11
|
+
if profile._raw_noise_re:
|
|
12
|
+
text = profile._raw_noise_re.sub(' ', text)
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
text = INLINE_SPLIT_RE.sub('\n', text)
|
|
15
|
+
lines = [line.strip() for line in text.split("\n") if line.strip()]
|
|
16
|
+
meta_matcher = profile._meta_line_re
|
|
17
|
+
|
|
15
18
|
normalized_chunks = []
|
|
16
19
|
for line in lines:
|
|
17
20
|
if meta_matcher and meta_matcher.search(line):
|
|
@@ -20,9 +23,8 @@ def clean_and_split_chunks(text: str, profile: DomainProfile, min_length: int =
|
|
|
20
23
|
if not line.endswith(('.', '!', '?', ';', ':')):
|
|
21
24
|
# If it's short (< 6 words) and lacks punctuation, it's a header line, do not append
|
|
22
25
|
if len(line.split()) < 6:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
line = line + "."
|
|
26
|
+
continue
|
|
27
|
+
line = line + "."
|
|
26
28
|
normalized_chunks.append(line)
|
|
27
29
|
|
|
28
30
|
normalized_text = " ".join(normalized_chunks)
|
|
@@ -33,11 +35,9 @@ def clean_and_split_chunks(text: str, profile: DomainProfile, min_length: int =
|
|
|
33
35
|
clean_sentences = []
|
|
34
36
|
for sentence in sentences:
|
|
35
37
|
sentence_clean = sentence.strip()
|
|
36
|
-
sentence_lower = sentence_clean.lower()
|
|
37
|
-
|
|
38
38
|
if len(sentence_clean) < min_length:
|
|
39
39
|
continue
|
|
40
|
-
if profile.
|
|
40
|
+
if profile._stop_phrases_re and profile._stop_phrases_re.search(sentence_clean):
|
|
41
41
|
continue
|
|
42
42
|
|
|
43
43
|
clean_sentences.append(sentence_clean)
|
|
@@ -74,7 +74,6 @@ class ParallelPreprocessingPipeline:
|
|
|
74
74
|
def _cpu_preprocess_chunk(self, raw_docs: list[str]) -> dict:
|
|
75
75
|
doc_sentence_counts = []
|
|
76
76
|
flat_sentences = []
|
|
77
|
-
flat_windows = []
|
|
78
77
|
|
|
79
78
|
for raw_text in raw_docs:
|
|
80
79
|
if not raw_text or not isinstance(raw_text, str):
|
|
@@ -86,36 +85,32 @@ class ParallelPreprocessingPipeline:
|
|
|
86
85
|
doc_sentence_counts.append(len(sents))
|
|
87
86
|
flat_sentences.extend(sents)
|
|
88
87
|
|
|
89
|
-
for i in range(n_sents):
|
|
90
|
-
window = " ".join(sents[max(0, i - 1) : min(n_sents, i + 2)])
|
|
91
|
-
flat_windows.append(window)
|
|
92
|
-
|
|
93
88
|
return {
|
|
94
89
|
"doc_sentence_counts": doc_sentence_counts,
|
|
95
90
|
"flat_sentences": flat_sentences,
|
|
96
|
-
"flat_windows": flat_windows,
|
|
97
91
|
"raw_docs_count": len(raw_docs)
|
|
98
92
|
}
|
|
99
93
|
|
|
100
94
|
def _gpu_execution_pass(self, prep_data: dict, threshold: float) -> list[str]:
|
|
101
95
|
flat_sentences = prep_data["flat_sentences"]
|
|
102
|
-
flat_windows = prep_data["flat_windows"]
|
|
103
96
|
doc_sentence_counts = prep_data["doc_sentence_counts"]
|
|
104
97
|
raw_docs_count = prep_data["raw_docs_count"]
|
|
105
98
|
|
|
106
99
|
if not flat_sentences:
|
|
107
100
|
return [""] * raw_docs_count
|
|
108
101
|
|
|
109
|
-
|
|
102
|
+
flat_candidates, flat_cand_embs, doc_cand_counts = self.router.route_sentences(
|
|
110
103
|
sentences=flat_sentences,
|
|
111
|
-
windows=flat_windows,
|
|
112
104
|
doc_sentence_counts=doc_sentence_counts,
|
|
113
105
|
batch_size=self.inference_batch_size,
|
|
114
106
|
)
|
|
115
107
|
|
|
116
108
|
filtered_doc_sentences = self.ranker.rank_and_filter(
|
|
117
|
-
|
|
118
|
-
|
|
109
|
+
flat_candidates=flat_candidates,
|
|
110
|
+
flat_cand_embs=flat_cand_embs,
|
|
111
|
+
doc_cand_counts=doc_cand_counts,
|
|
112
|
+
cross_prob_threshold=threshold,
|
|
113
|
+
batch_size=self.inference_batch_size,
|
|
119
114
|
)
|
|
120
115
|
|
|
121
116
|
return [sanitize_outputs(sents, self.profile) for sents in filtered_doc_sentences]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from dataclasses import dataclass, field, asdict
|
|
2
2
|
import json
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
import re
|
|
4
5
|
|
|
5
6
|
@dataclass
|
|
6
7
|
class DomainProfile:
|
|
@@ -20,12 +21,24 @@ class DomainProfile:
|
|
|
20
21
|
metadata_line_regex: str | None = None
|
|
21
22
|
junk_line_regex: str | None = None
|
|
22
23
|
|
|
24
|
+
def __post_init__(self):
|
|
25
|
+
self._raw_noise_re = re.compile(self.raw_noise_regex, re.IGNORECASE) if self.raw_noise_regex else None
|
|
26
|
+
self._meta_line_re = re.compile(self.metadata_line_regex, re.IGNORECASE) if self.metadata_line_regex else None
|
|
27
|
+
self._junk_line_re = re.compile(self.junk_line_regex, re.IGNORECASE) if self.junk_line_regex else None
|
|
28
|
+
self._action_kw_re = re.compile(self.action_keywords_regex, re.IGNORECASE) if self.action_keywords_regex else None
|
|
29
|
+
self._syntax_start_re = re.compile(self.syntax_start_pattern, re.IGNORECASE) if self.syntax_start_pattern else None
|
|
30
|
+
if self.global_stop_phrases:
|
|
31
|
+
pattern = "|".join(map(re.escape, self.global_stop_phrases))
|
|
32
|
+
self._stop_phrases_re = re.compile(rf"(?:{pattern})", re.IGNORECASE)
|
|
33
|
+
else:
|
|
34
|
+
self._stop_phrases_re = None
|
|
35
|
+
|
|
23
36
|
def validate(self):
|
|
24
37
|
if not self.positive_semantic_anchors:
|
|
25
38
|
raise ValueError("DomainProfile must define at least one positive_semantic_anchor.")
|
|
26
|
-
if not self.name.isalnum():
|
|
27
|
-
raise ValueError("Profile name must be alphanumeric
|
|
28
|
-
if not self.
|
|
39
|
+
if not self.name.replace(" ", "").replace("_", "").isalnum():
|
|
40
|
+
raise ValueError("Profile name must be alphanumeric.")
|
|
41
|
+
if not self.anchors:
|
|
29
42
|
raise ValueError("DomainProfile must define at least one section anchor.")
|
|
30
43
|
if self.default_section not in self.anchors:
|
|
31
44
|
raise ValueError(f"Default section '{self.default_section}' must be one of the defined section anchors.")
|
|
@@ -75,12 +88,12 @@ JOB_POSTING_PROFILE = DomainProfile(
|
|
|
75
88
|
default_section="CORE",
|
|
76
89
|
target_sections=['CORE', 'REQUIREMENTS'],
|
|
77
90
|
positive_semantic_anchors=[
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
82
|
-
"
|
|
83
|
-
"
|
|
91
|
+
"What are the core daily duties, responsibilities, and operational tasks performed in this role?",
|
|
92
|
+
"Responsible for designing, building, developing, and maintaining technical systems, software, or equipment.",
|
|
93
|
+
"Performs data analysis, conducts research, prepares reports, formulates budgets, and monitors program metrics.",
|
|
94
|
+
"Collaborates with cross-functional teams, advises stakeholders, and coordinates activities across departments.",
|
|
95
|
+
"Ensures regulatory compliance, performs inspections or clinical care, manages inventory, and delivers services.",
|
|
96
|
+
"Supervises staff, assigns work to subordinates, leads projects, and manages organizational resources."
|
|
84
97
|
],
|
|
85
98
|
negative_semantic_anchors=[
|
|
86
99
|
"company history, corporate mission statement, organizational culture, workplace values, company overview",
|
|
@@ -58,79 +58,69 @@ class HybridRanker:
|
|
|
58
58
|
|
|
59
59
|
def rank_and_filter(
|
|
60
60
|
self,
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
flat_candidates: list[str],
|
|
62
|
+
flat_cand_embs: torch.Tensor,
|
|
63
|
+
doc_cand_counts: list[int],
|
|
64
|
+
high_certainty_threshold: float = 0.40,
|
|
63
65
|
low_certainty_threshold: float = 0.20,
|
|
66
|
+
cross_prob_threshold: float = 0.30,
|
|
67
|
+
min_fallback_sentences: int = 3,
|
|
64
68
|
batch_size: int = 512
|
|
65
69
|
) -> list[list[str]]:
|
|
66
|
-
flat_candidates = []
|
|
67
|
-
doc_cand_counts = []
|
|
68
|
-
for c_list in sentences:
|
|
69
|
-
doc_cand_counts.append(len(c_list))
|
|
70
|
-
flat_candidates.extend(c_list)
|
|
71
|
-
|
|
72
70
|
if not flat_candidates:
|
|
73
|
-
return [[] for _ in
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
return [[] for _ in doc_cand_counts]
|
|
72
|
+
|
|
73
|
+
action_re = self.profile._action_kw_re
|
|
74
|
+
if action_re:
|
|
75
|
+
lexical_boost = torch.tensor(
|
|
76
|
+
[0.06 if action_re.search(s) else 0.0 for s in flat_candidates],
|
|
77
|
+
dtype=flat_cand_embs.dtype,
|
|
78
|
+
device=flat_cand_embs.device
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
lexical_boost = 0.0
|
|
77
82
|
|
|
78
83
|
with torch.inference_mode():
|
|
79
|
-
emb_map = {s: norm_unique[i] for i, s in enumerate(unique_candidates)}
|
|
80
|
-
flat_cand_embs = torch.stack([emb_map[s] for s in flat_candidates])
|
|
81
|
-
|
|
82
84
|
pos_sim = torch.matmul(flat_cand_embs, self.pos_anchors.T)
|
|
83
85
|
max_pos, _ = torch.max(pos_sim, dim=1)
|
|
84
86
|
|
|
85
87
|
if self.neg_anchors is not None:
|
|
86
88
|
neg_sim = torch.matmul(flat_cand_embs, self.neg_anchors.T)
|
|
87
89
|
max_neg, _ = torch.max(neg_sim, dim=1)
|
|
88
|
-
|
|
90
|
+
# Only penalize when negative similarity is genuinely high (> 0.30)
|
|
91
|
+
neg_penalty = torch.clamp(max_neg - 0.30, min=0.0) * 0.6
|
|
92
|
+
net_scores = max_pos - neg_penalty + lexical_boost
|
|
89
93
|
else:
|
|
90
|
-
net_scores = max_pos
|
|
94
|
+
net_scores = max_pos + lexical_boost
|
|
91
95
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
else:
|
|
96
|
-
norm_scores = net_scores
|
|
97
|
-
|
|
98
|
-
auto_mask = (norm_scores >= high_certainty_threshold).cpu().tolist()
|
|
99
|
-
cross_mask = (
|
|
100
|
-
(norm_scores >= low_certainty_threshold)
|
|
101
|
-
& (norm_scores < high_certainty_threshold)
|
|
102
|
-
).cpu().tolist()
|
|
103
|
-
needs_cross = list(
|
|
104
|
-
dict.fromkeys(
|
|
105
|
-
[s for s, flag in zip(flat_candidates, cross_mask) if flag]
|
|
106
|
-
)
|
|
107
|
-
)
|
|
96
|
+
auto_mask = (net_scores >= high_certainty_threshold).cpu().tolist()
|
|
97
|
+
cross_mask = ((net_scores >= low_certainty_threshold) & (net_scores < high_certainty_threshold)).cpu().tolist()
|
|
98
|
+
net_scores_cpu = net_scores.cpu().tolist()
|
|
108
99
|
|
|
100
|
+
needs_cross = list(dict.fromkeys([s for s, flag in zip(flat_candidates, cross_mask) if flag]))
|
|
109
101
|
cross_keep_set = set()
|
|
110
102
|
if needs_cross:
|
|
103
|
+
needs_cross_sorted = sorted(needs_cross, key=len)
|
|
111
104
|
primary_anchor = self.profile.positive_semantic_anchors[0]
|
|
112
|
-
cross_inputs = [[primary_anchor, sent] for sent in
|
|
105
|
+
cross_inputs = [[primary_anchor, sent] for sent in needs_cross_sorted]
|
|
113
106
|
|
|
114
107
|
cross_scores = self.cross_encoder.predict(
|
|
115
108
|
cross_inputs,
|
|
116
109
|
batch_size=batch_size,
|
|
117
110
|
show_progress_bar=False,
|
|
118
|
-
convert_to_tensor=True
|
|
111
|
+
convert_to_tensor=True
|
|
119
112
|
)
|
|
120
113
|
|
|
121
114
|
with torch.inference_mode():
|
|
122
|
-
|
|
115
|
+
cross_probs = torch.sigmoid(cross_scores)
|
|
116
|
+
survived_flags = (cross_probs >= cross_prob_threshold).cpu().tolist()
|
|
123
117
|
|
|
124
|
-
cross_keep_set =
|
|
125
|
-
[sent for sent, keep in zip(needs_cross, survived_flags) if keep]
|
|
126
|
-
)
|
|
118
|
+
cross_keep_set = {sent for sent, keep in zip(needs_cross, survived_flags) if keep}
|
|
127
119
|
|
|
128
|
-
surviving_mask = [
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
elif cross_mask[idx] and (flat_candidates[idx] in cross_keep_set):
|
|
133
|
-
surviving_mask[idx] = True
|
|
120
|
+
surviving_mask = [
|
|
121
|
+
is_auto or (is_cross and (flat_candidates[idx] in cross_keep_set))
|
|
122
|
+
for idx, (is_auto, is_cross) in enumerate(zip(auto_mask, cross_mask))
|
|
123
|
+
]
|
|
134
124
|
|
|
135
125
|
filtered_docs = []
|
|
136
126
|
c_offset = 0
|
|
@@ -141,7 +131,21 @@ class HybridRanker:
|
|
|
141
131
|
|
|
142
132
|
doc_pool = flat_candidates[c_offset : c_offset + c_count]
|
|
143
133
|
doc_mask = surviving_mask[c_offset : c_offset + c_count]
|
|
144
|
-
|
|
134
|
+
kept = [sent for sent, keep in zip(doc_pool, doc_mask) if keep]
|
|
135
|
+
|
|
136
|
+
if not kept and min_fallback_sentences > 0:
|
|
137
|
+
doc_scores = net_scores_cpu[c_offset : c_offset + c_count]
|
|
138
|
+
# Rank indices by score descending, keep top K that have at least > 0.10 score
|
|
139
|
+
ranked_local_idxs = sorted(
|
|
140
|
+
range(c_count), key=lambda i: doc_scores[i], reverse=True
|
|
141
|
+
)
|
|
142
|
+
rescued_idxs = sorted([
|
|
143
|
+
i for i in ranked_local_idxs[:min_fallback_sentences]
|
|
144
|
+
if doc_scores[i] > 0.10
|
|
145
|
+
])
|
|
146
|
+
kept = [doc_pool[i] for i in rescued_idxs]
|
|
147
|
+
|
|
148
|
+
filtered_docs.append(kept)
|
|
145
149
|
c_offset += c_count
|
|
146
150
|
|
|
147
151
|
return filtered_docs
|
|
@@ -45,8 +45,7 @@ class SemanticSectionRouter:
|
|
|
45
45
|
|
|
46
46
|
def route_sentences(
|
|
47
47
|
self,
|
|
48
|
-
sentences: list[str],
|
|
49
|
-
windows: list[str],
|
|
48
|
+
sentences: list[str],
|
|
50
49
|
doc_sentence_counts: list[int],
|
|
51
50
|
min_confidence: float = 0.20,
|
|
52
51
|
batch_size: int = 512
|
|
@@ -54,39 +53,46 @@ class SemanticSectionRouter:
|
|
|
54
53
|
if not sentences:
|
|
55
54
|
return [[] for _ in doc_sentence_counts]
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
unique_sents = list(dict.fromkeys(sentences))
|
|
57
|
+
norm_unique = self._encode_sorted(unique_sents, batch_size=batch_size)
|
|
58
58
|
|
|
59
59
|
with torch.inference_mode():
|
|
60
|
+
emb_map = {s: norm_unique[i] for i, s in enumerate(unique_sents)}
|
|
61
|
+
sent_embs = torch.stack([emb_map[s] for s in sentences])
|
|
62
|
+
|
|
63
|
+
# 2. Compute 3-sentence context windows in VRAM via 1D avg pooling (0ms model cost!)
|
|
64
|
+
padded = torch.nn.functional.pad(sent_embs.unsqueeze(0).transpose(1, 2), (1, 1), mode="replicate")
|
|
65
|
+
window_embs = torch.nn.functional.avg_pool1d(padded, kernel_size=3, stride=1).transpose(1, 2).squeeze(0)
|
|
66
|
+
window_embs = torch.nn.functional.normalize(window_embs, p=2, dim=1)
|
|
67
|
+
|
|
60
68
|
sim_sections = torch.matmul(window_embs, self.anchor_embeddings.T)
|
|
61
69
|
best_scores, best_indices = torch.max(sim_sections, dim=1)
|
|
62
70
|
|
|
63
71
|
confident_mask = best_scores >= min_confidence
|
|
64
72
|
fallback_tensor = torch.full_like(best_indices, self.default_idx)
|
|
65
|
-
routed_indices = torch.where(
|
|
66
|
-
confident_mask, best_indices, fallback_tensor
|
|
67
|
-
).cpu().tolist()
|
|
73
|
+
routed_indices = torch.where(confident_mask, best_indices, fallback_tensor).cpu().tolist()
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
target_idx_set = {self.section_keys.index(k) for k in self.profile.target_sections if k in self.section_keys}
|
|
76
|
+
flat_candidates = []
|
|
77
|
+
keep_emb_indices = []
|
|
78
|
+
doc_cand_counts = []
|
|
71
79
|
curr_offset = 0
|
|
72
80
|
|
|
73
81
|
for count in doc_sentence_counts:
|
|
74
82
|
if count == 0:
|
|
75
|
-
|
|
83
|
+
doc_cand_counts.append(0)
|
|
76
84
|
continue
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for idx in routed_indices[curr_offset : curr_offset + count]
|
|
86
|
+
doc_indices = [
|
|
87
|
+
curr_offset + i for i in range(count)
|
|
88
|
+
if routed_indices[curr_offset + i] in target_idx_set
|
|
82
89
|
]
|
|
90
|
+
if len(doc_indices) < min(3, count):
|
|
91
|
+
doc_indices = list(range(curr_offset, curr_offset + count))
|
|
83
92
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
targets = doc_sents
|
|
88
|
-
|
|
89
|
-
doc_candidates.append(targets)
|
|
93
|
+
doc_cand_counts.append(len(doc_indices))
|
|
94
|
+
keep_emb_indices.extend(doc_indices)
|
|
95
|
+
flat_candidates.extend([sentences[i] for i in doc_indices])
|
|
90
96
|
curr_offset += count
|
|
91
97
|
|
|
92
|
-
return
|
|
98
|
+
return flat_candidates, sent_embs[keep_emb_indices], doc_cand_counts
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dstpr
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: A high-throughput, domain-specific text preprocessing cascading pipeline filter to rank core sentences in texts and filter away boilerplate.
|
|
5
5
|
Author-email: Stephen Meisenbacher <sjmeis@gtgd.com>
|
|
6
6
|
Maintainer-email: Stephen Meisenbacher <sjmeis@gtgd.com>
|
|
@@ -76,7 +76,7 @@ This is the recommended approach for large-scale data pipelines.
|
|
|
76
76
|
```python
|
|
77
77
|
from dstpr import ParallelPreprocessingPipeline, JOB_POSTING_PROFILE
|
|
78
78
|
|
|
79
|
-
pipeline = ParallelPreprocessingPipeline(profile=JOB_POSTING_PROFILE
|
|
79
|
+
pipeline = ParallelPreprocessingPipeline(profile=JOB_POSTING_PROFILE)
|
|
80
80
|
|
|
81
81
|
raw_documents = [
|
|
82
82
|
"DOC 1",
|
|
@@ -84,41 +84,13 @@ raw_documents = [
|
|
|
84
84
|
"..."
|
|
85
85
|
]
|
|
86
86
|
|
|
87
|
-
cleaned_documents = pipeline.process(raw_documents,
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
### Advanced Usage
|
|
91
|
-
|
|
92
|
-
If you want to integrate specific pipeline layers directly into an existing workflow, or tweak the internal parameters, you can import individual modules manually:
|
|
93
|
-
|
|
94
|
-
```python
|
|
95
|
-
from dstpr.cleaners import clean_and_split_chunks
|
|
96
|
-
from dstpr.segmenters import SemanticSectionRouter
|
|
97
|
-
from dstpr.rankers import HybridRanker
|
|
98
|
-
from sentence_transformers import SentenceTransformer
|
|
99
|
-
|
|
100
|
-
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
101
|
-
|
|
102
|
-
text = "Job posting text goes here!"
|
|
103
|
-
|
|
104
|
-
# Clean and segment sentences
|
|
105
|
-
sentences = clean_and_split_chunks(text)
|
|
106
|
-
|
|
107
|
-
# Route by context
|
|
108
|
-
router = SemanticSectionRouter(model=embedding_model)
|
|
109
|
-
buckets = router.route_sentences(sentences)
|
|
110
|
-
target_sentences = buckets['CORE'] + buckets['REQUIREMENTS']
|
|
111
|
-
|
|
112
|
-
# Grade and sort text features
|
|
113
|
-
ranker = HybridRanker()
|
|
114
|
-
final = ranker.rank_and_filter(target_sentences, bi_cutoff_pct=0.25, final_threshold=0.40)
|
|
87
|
+
cleaned_documents = pipeline.process(raw_documents, threshold=0.25)
|
|
115
88
|
```
|
|
116
89
|
|
|
117
90
|
### Parameter Tuning
|
|
118
91
|
|
|
119
92
|
To adjust the trade-off between strict filtering and execution speed, consider tweaking these variables in `ParallelPreprocessingPipeline`:
|
|
120
93
|
- `threshold` (Default: `0.4`): Controls how aggressively sentences are discarded. Raising this value toward `0.6`, for example, ensures only stronger matching sentences are passed through. Lowering it toward `0.3`, on the other hand, acts as a wider net.
|
|
121
|
-
- `batch_size` (Default: `256`): This depends on your hardware! Adjust for best performance.
|
|
122
94
|
|
|
123
95
|
## Creating Custom Domain Profiles (via the provided wizard)
|
|
124
96
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dstpr"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "A high-throughput, domain-specific text preprocessing cascading pipeline filter to rank core sentences in texts and filter away boilerplate."
|
|
9
9
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
10
10
|
license = {file = "LICENSE"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|