blob: 043270991a9eafea730b5495442bc508b1493154 [file] [log] [blame]
Marc Kupietz86044852025-11-29 10:19:03 +01001from sys import stdin
2import argparse, os
3import spacy
4from spacy.tokens import Doc
5import logging, sys, time, signal
Marc Kupietz2d421912026-06-10 07:25:33 +02006from lib.CoNLL_Annotation import get_token_type, read_conll
Marc Kupietz86044852025-11-29 10:19:03 +01007import my_utils.file_utils as fu
Marc Kupietz9baa27a2025-11-29 15:32:16 +01008
9# Try to import GermaLemma, but make it optional
10try:
11 from germalemma import GermaLemma
12 GERMALEMMA_AVAILABLE = True
13except ImportError:
14 GERMALEMMA_AVAILABLE = False
15 GermaLemma = None
Marc Kupietz86044852025-11-29 10:19:03 +010016
17# Dependency parsing safety limits
18DEFAULT_PARSE_TIMEOUT = 0.5 # seconds per sentence
19DEFAULT_MAX_SENTENCE_LENGTH = 500 # tokens
20
21class TimeoutException(Exception):
22 pass
23
24def timeout_handler(signum, frame):
25 raise TimeoutException("Dependency parsing timeout")
26
27def safe_dependency_parse(spacy_model, text, timeout=DEFAULT_PARSE_TIMEOUT, max_length=DEFAULT_MAX_SENTENCE_LENGTH):
28 """
29 Safely parse a sentence with timeout and length limits.
30
31 Args:
32 spacy_model: Loaded spaCy model
33 text: Text to parse
34 timeout: Maximum seconds to wait for parsing
35 max_length: Maximum sentence length in tokens
36
37 Returns:
38 tuple: (spacy_doc, success, warning_message)
39 """
40 # Check sentence length
41 if len(text.split()) > max_length:
42 # Process without dependency parsing for long sentences
43 disabled_components = ["ner", "parser"]
44 doc = spacy_model(text, disable=disabled_components)
45 return doc, False, f"Sentence too long ({len(text.split())} tokens > {max_length}), dependency parsing skipped"
46
47 # Set up timeout
48 old_handler = signal.signal(signal.SIGALRM, timeout_handler)
49 signal.setitimer(signal.ITIMER_REAL, timeout)
50
51 try:
52 doc = spacy_model(text)
53 signal.setitimer(signal.ITIMER_REAL, 0) # Cancel alarm
54 signal.signal(signal.SIGALRM, old_handler)
55 return doc, True, None
56 except TimeoutException:
57 signal.setitimer(signal.ITIMER_REAL, 0) # Cancel alarm
58 signal.signal(signal.SIGALRM, old_handler)
59 # Retry without dependency parsing
60 disabled_components = ["ner", "parser"]
61 doc = spacy_model(text, disable=disabled_components)
62 return doc, False, f"Dependency parsing timeout after {timeout}s, processed without dependencies"
63 except Exception as e:
64 signal.setitimer(signal.ITIMER_REAL, 0) # Cancel alarm
65 signal.signal(signal.SIGALRM, old_handler)
66 # Retry without dependency parsing
67 disabled_components = ["ner", "parser"]
68 doc = spacy_model(text, disable=disabled_components)
69 return doc, False, f"Dependency parsing error: {str(e)}, processed without dependencies"
70
71def format_morphological_features(token):
72 """
73 Extract and format morphological features from a spaCy token for CoNLL-U output.
74
75 Args:
76 token: spaCy token object
77
78 Returns:
79 str: Formatted morphological features string for CoNLL-U 5th column
80 Returns "_" if no features are available
81 """
82 if not hasattr(token, 'morph') or not token.morph:
83 return "_"
84
85 morph_dict = token.morph.to_dict()
86 if not morph_dict:
87 return "_"
88
89 # Format as CoNLL-U format: Feature=Value|Feature2=Value2
90 features = []
91 for feature, value in sorted(morph_dict.items()):
92 features.append(f"{feature}={value}")
93
94 return "|".join(features)
95
96
97def format_dependency_relations(doc):
98 """
99 Extract and format dependency relations from a spaCy doc for CoNLL-U output.
100
101 Args:
102 doc: spaCy Doc object
103
104 Returns:
105 list: List of tuples (head_id, deprel) for each token
106 """
107 dependencies = []
108 for i, token in enumerate(doc):
109 # HEAD column: 1-based index of the head token (0 for root)
110 if token.dep_ == "ROOT":
111 head_id = 0
112 else:
113 # Find the 1-based index of the head token
114 head_id = None
115 for j, potential_head in enumerate(doc):
116 if potential_head == token.head:
117 head_id = j + 1
118 break
119 if head_id is None:
120 head_id = 0 # Fallback to root if head not found
121
122 # DEPREL column: dependency relation
123 deprel = token.dep_ if token.dep_ else "_"
124
125 dependencies.append((head_id, deprel))
126
127 return dependencies
128
129
130class WhitespaceTokenizer(object):
131 def __init__(self, vocab):
132 self.vocab = vocab
133
134 def __call__(self, text):
135 words = text.split(' ')
136 # Filter out empty strings to avoid spaCy errors
137 words = [w for w in words if w]
138 # Handle edge case of empty input - use a placeholder token
139 if not words:
140 words = ['_EMPTY_']
141 # All tokens 'own' a subsequent space character in this tokenizer
142 spaces = [True] * len(words)
143 return Doc(self.vocab, words=words, spaces=spaces)
144
145
146def get_conll_str(anno_obj, spacy_doc, use_germalemma, use_dependencies):
147 # First lines are comments. (metadata)
148 conll_lines = anno_obj.metadata # Then we want: [ID, FORM, LEMMA, UPOS, XPOS, FEATS, HEAD, DEPREL, DEPS, MISC]
149
150 # Get dependency relations if enabled
151 dependencies = format_dependency_relations(spacy_doc) if use_dependencies == "True" else None
152
153 for ix, token in enumerate(spacy_doc):
154 morph_features = format_morphological_features(token)
155
156 # Get HEAD and DEPREL columns
157 if dependencies:
158 head_id, deprel = dependencies[ix]
159 else:
160 head_id, deprel = "_", "_"
161
162 if use_germalemma == "True":
163 content = (str(ix+1), token.text, find_germalemma(token.text, token.tag_, token.lemma_), token.pos_, token.tag_, morph_features, str(head_id), deprel, "_", "_")
164 else:
165 content = (str(ix+1), token.text, token.lemma_, token.pos_, token.tag_, morph_features, str(head_id), deprel, "_", "_") # Pure SpaCy!
166 conll_lines.append("\t".join(content))
167 return "\n".join(conll_lines)
168
169
170def find_germalemma(word, pos, spacy_lemma):
171 simplify_pos = {"ADJA":"ADJ", "ADJD":"ADJ",
172 "NA":"N", "NE":"N", "NN":"N",
173 "ADV":"ADV", "PAV":"ADV", "PROAV":"ADV", "PAVREL":"ADV", "PWAV":"ADV", "PWAVREL":"ADV",
174 "VAFIN":"V", "VAIMP":"V", "VAINF":"V", "VAPP":"V", "VMFIN":"V", "VMINF":"V",
175 "VMPP":"V", "VVFIN":"V", "VVIMP":"V", "VVINF":"V", "VVIZU":"V","VVPP":"V"
176 }
177 # simplify_pos = {"VERB": "V", "ADV": "ADV", "ADJ": "ADJ", "NOUN":"N", "PROPN": "N"}
178 try:
179 return lemmatizer.find_lemma(word, simplify_pos.get(pos, "UNK"))
180 except:
181 return spacy_lemma
182
183
Marc Kupietz2d421912026-06-10 07:25:33 +0200184def iter_documents(line_generator, chunk_size):
185 """
186 Stream the input as a sequence of (lines, terminator) blocks.
187
188 Honors the korapxmltool worker-pool protocol: a line that is exactly
189 "# eot" or "# eof" ends a document, and the marker is returned as the
190 terminator ("eot"/"eof") so the caller can echo it back and flush. That
191 lets the worker pool deliver each document's result and release its
192 bounded in-flight buffer slot immediately, instead of deadlocking because
193 nothing is emitted until the process exits.
194
195 When the stream carries no such markers (e.g. a CoNLL-U file piped straight
196 in), the buffer is flushed every `chunk_size` completed sentences with
197 terminator None, keeping memory bounded as the previous chunked reader did.
198 """
199 buffer = []
200 n_sents = 0
201 for line in line_generator:
202 marker = line.rstrip("\r\n")
203 if marker == "# eot":
204 yield buffer, "eot"
205 buffer, n_sents = [], 0
206 continue
207 if marker == "# eof":
208 yield buffer, "eof"
209 return
210 buffer.append(line)
211 if marker.strip() == "":
212 n_sents += 1
213 if chunk_size > 0 and n_sents >= chunk_size:
214 yield buffer, None
215 buffer, n_sents = [], 0
216 if buffer:
217 yield buffer, None
218
219
Marc Kupietz86044852025-11-29 10:19:03 +0100220if __name__ == "__main__":
221 """
222 --- Example Real Data TEST ---
223
224 cat /export/netapp/kupietz/N-GRAMM-STUDIE/conllu/zca18.conllu | python systems/parse_spacy_pipe.py \
225 --corpus_name DeReKo_zca18 --comment_str "#" > output_zca18.conll
226 """
227
228 parser = argparse.ArgumentParser()
229 parser.add_argument("-n", "--corpus_name", help="Corpus Name", default="Corpus")
230 parser.add_argument("-sm", "--spacy_model", help="Spacy model containing the pipeline to tag", default="de_core_news_lg")
231 parser.add_argument("-gtt", "--gld_token_type", help="CoNLL Format of the Gold Data", default="CoNLLUP_Token")
232 parser.add_argument("-ugl", "--use_germalemma", help="Use Germalemma lemmatizer on top of SpaCy", default="True")
233 parser.add_argument("-udp", "--use_dependencies", help="Include dependency parsing (adds HEAD/DEPREL columns, set to False for faster processing)", default="True")
234 parser.add_argument("-c", "--comment_str", help="CoNLL Format of comentaries inside the file", default="#")
235 args = parser.parse_args()
Marc Kupietz2d421912026-06-10 07:25:33 +0200236
Marc Kupietz86044852025-11-29 10:19:03 +0100237 CHUNK_SIZE = int(os.getenv("SPACY_CHUNK_SIZE", "20000"))
238 SPACY_BATCH = int(os.getenv("SPACY_BATCH_SIZE", "2000"))
239 SPACY_PROC = int(os.getenv("SPACY_N_PROCESS", "1"))
240
241 # =====================================================================================
242 # LOGGING INFO ...
243 # =====================================================================================
244 logger = logging.getLogger(__name__)
245 console_hdlr = logging.StreamHandler(sys.stderr)
246 file_hdlr = logging.FileHandler(filename=f"logs/Parse_{args.corpus_name}.SpaCy.log")
247
248 # Custom format without module name
249 formatter = logging.Formatter('%(levelname)s: %(message)s')
250 console_hdlr.setFormatter(formatter)
251 file_hdlr.setFormatter(formatter)
252
253 logging.basicConfig(level=logging.INFO, handlers=[console_hdlr, file_hdlr])
254
255 # Override with environment variables if set (useful for Docker)
256 import os
257 if os.getenv("SPACY_USE_DEPENDENCIES") is not None:
258 args.use_dependencies = os.getenv("SPACY_USE_DEPENDENCIES", "True")
259 logger.info(f"Using SPACY_USE_DEPENDENCIES environment variable: {args.use_dependencies}")
260
261 if os.getenv("SPACY_USE_GERMALEMMA") is not None:
262 args.use_germalemma = os.getenv("SPACY_USE_GERMALEMMA", "True")
263 logger.info(f"Using SPACY_USE_GERMALEMMA environment variable: {args.use_germalemma}")
264
265 logger.info(f"Chunking {args.corpus_name} Corpus in chunks of {CHUNK_SIZE} Sentences")
266 logger.info(f"Processing configuration: batch_size={SPACY_BATCH}, n_process={SPACY_PROC}")
267
268 # =====================================================================================
269 # POS TAG DOCUMENTS
270 # =====================================================================================
271 # Configure which components to disable based on dependency parsing option
272 disabled_components = ["ner"]
273 if args.use_dependencies != "True":
274 disabled_components.append("parser")
275 logger.info("Dependency parsing disabled for faster processing")
276 else:
277 logger.info("Dependency parsing enabled (slower but includes HEAD/DEPREL)")
278
279 spacy_de = spacy.load(args.spacy_model, disable=disabled_components)
280 spacy_de.tokenizer = WhitespaceTokenizer(spacy_de.vocab) # We won't re-tokenize to respect how the source CoNLL are tokenized!
Marc Kupietz9baa27a2025-11-29 15:32:16 +0100281
Marc Kupietz86044852025-11-29 10:19:03 +0100282 # Increase max_length to handle very long sentences (especially when parser is disabled)
283 spacy_de.max_length = 10000000 # 10M characters
Marc Kupietz9baa27a2025-11-29 15:32:16 +0100284
285 # Initialize GermaLemma if available and requested
286 lemmatizer = None
287 if args.use_germalemma == "True":
288 if GERMALEMMA_AVAILABLE:
289 lemmatizer = GermaLemma()
290 else:
291 logger.warning("GermaLemma requested but not available. Using spaCy lemmatizer instead.")
292 args.use_germalemma = "False"
Marc Kupietz86044852025-11-29 10:19:03 +0100293
294 # Log version information
295 logger.info(f"spaCy version: {spacy.__version__}")
296 logger.info(f"spaCy model: {args.spacy_model}")
297 logger.info(f"spaCy model version: {spacy_de.meta.get('version', 'unknown')}")
Marc Kupietz9baa27a2025-11-29 15:32:16 +0100298 if GERMALEMMA_AVAILABLE:
299 try:
300 import germalemma
301 logger.info(f"GermaLemma version: {germalemma.__version__}")
302 except AttributeError:
303 logger.info("GermaLemma version: unknown (no __version__ attribute)")
304 else:
305 logger.info("GermaLemma: not installed")
Marc Kupietz86044852025-11-29 10:19:03 +0100306
307 # Parse timeout and sentence length limits from environment variables
308 parse_timeout = float(os.getenv("SPACY_PARSE_TIMEOUT", str(DEFAULT_PARSE_TIMEOUT)))
309 max_sentence_length = int(os.getenv("SPACY_MAX_SENTENCE_LENGTH", str(DEFAULT_MAX_SENTENCE_LENGTH)))
310
311 logger.info(f"Dependency parsing limits: timeout={parse_timeout}s, max_length={max_sentence_length} tokens")
312
313 start = time.time()
314 total_processed_sents = 0
315 dependency_warnings = 0
316
Marc Kupietz2d421912026-06-10 07:25:33 +0200317 token_class = get_token_type(args.gld_token_type)
318
319 def annotate(annos, base_sent_no):
320 """Annotate a document's sentences, write CoNLL-U to stdout, return the dependency-warning count."""
321 warnings = 0
Marc Kupietz86044852025-11-29 10:19:03 +0100322 sents = [a.get_sentence() for a in annos]
Marc Kupietz2d421912026-06-10 07:25:33 +0200323
Marc Kupietz86044852025-11-29 10:19:03 +0100324 # Process sentences individually when dependency parsing is enabled for timeout protection
325 if args.use_dependencies == "True":
326 for ix, sent in enumerate(sents):
327 doc, dependency_success, warning = safe_dependency_parse(
328 spacy_de, sent, timeout=parse_timeout, max_length=max_sentence_length
329 )
330 if warning:
Marc Kupietz2d421912026-06-10 07:25:33 +0200331 warnings += 1
332 logger.warning(f"Sentence {base_sent_no + ix + 1}: {warning}")
333
Marc Kupietz86044852025-11-29 10:19:03 +0100334 # Override use_dependencies based on actual parsing success
335 actual_use_dependencies = "True" if dependency_success else "False"
336 conll_str = get_conll_str(annos[ix], doc, use_germalemma=args.use_germalemma, use_dependencies=actual_use_dependencies)
337 print(conll_str+ "\n")
338 else:
339 # Use batch processing for faster processing when dependencies are disabled
340 # Use n_process=1 to avoid multiprocessing deadlocks and memory issues with large files
341 try:
342 for ix, doc in enumerate(spacy_de.pipe(sents, batch_size=SPACY_BATCH, n_process=1)):
343 conll_str = get_conll_str(annos[ix], doc, use_germalemma=args.use_germalemma, use_dependencies=args.use_dependencies)
344 print(conll_str+ "\n")
345 except Exception as e:
346 logger.error(f"Batch processing failed: {str(e)}")
347 logger.info("Falling back to individual sentence processing...")
348 # Fallback: process sentences individually
349 for ix, sent in enumerate(sents):
350 try:
351 doc = spacy_de(sent)
352 conll_str = get_conll_str(annos[ix], doc, use_germalemma=args.use_germalemma, use_dependencies=args.use_dependencies)
353 print(conll_str+ "\n")
354 except Exception as sent_error:
Marc Kupietz2d421912026-06-10 07:25:33 +0200355 logger.error(f"Failed to process sentence {base_sent_no + ix + 1}: {str(sent_error)}")
Marc Kupietz86044852025-11-29 10:19:03 +0100356 logger.error(f"Sentence preview: {sent[:100]}...")
357 # Output a placeholder to maintain alignment
358 conll_str = get_conll_str(annos[ix], spacy_de("ERROR"), use_germalemma=args.use_germalemma, use_dependencies=args.use_dependencies)
359 print(conll_str+ "\n")
Marc Kupietz2d421912026-06-10 07:25:33 +0200360 return warnings
361
362 # Stream the input document-by-document. Each "# eot"/"# eof" delimited
363 # document is annotated, emitted, and flushed immediately so korapxmltool's
364 # worker pool can deliver the result and release its in-flight buffer slot
365 # (avoiding the deadlock on large corpora with many small documents).
366 # Inputs without protocol markers (a CoNLL-U file piped in directly) are
367 # still processed in CHUNK_SIZE-bounded blocks and streamed out.
368 last_log_time = start
369 for block_lines, terminator in iter_documents(stdin, CHUNK_SIZE):
370 annos, _ = read_conll(iter(block_lines), 0, token_class=token_class, comment_str=args.comment_str, our_foundry="spacy")
371 if annos:
372 dependency_warnings += annotate(annos, total_processed_sents)
373 total_processed_sents += len(annos)
374
375 # Echo the protocol marker back so korapxmltool can pair the output with
376 # the source document and release the in-flight buffer slot, then flush
377 # so the bytes actually reach the reader instead of sitting in stdout's
378 # block buffer.
379 if terminator == "eot":
380 sys.stdout.write("# eot\n")
381 elif terminator == "eof":
382 sys.stdout.write("# eof\n")
383 sys.stdout.flush()
384
385 # Throttle progress logging so per-document streaming doesn't flood the log.
386 now = time.time()
387 if now - last_log_time >= 2.0 or terminator == "eof":
388 last_log_time = now
389 elapsed_time = now - start
390 sents_per_sec = total_processed_sents / elapsed_time if elapsed_time > 0 else 0
391 current_time = time.strftime("%Y-%m-%d %H:%M:%S")
392 logger.info(f"{current_time} | Processed: {total_processed_sents} sentences | Elapsed: {elapsed_time:.1f}s | Speed: {sents_per_sec:.1f} sents/sec")
393
394 if terminator == "eof":
395 break
396
Marc Kupietz86044852025-11-29 10:19:03 +0100397 end = time.time()
398 total_time = end - start
399 final_sents_per_sec = total_processed_sents / total_time if total_time > 0 else 0
400
401 logger.info(f"=== Processing Complete ===")
402 logger.info(f"Total sentences: {total_processed_sents}")
403 logger.info(f"Total time: {total_time:.2f}s")
404 logger.info(f"Average speed: {final_sents_per_sec:.1f} sents/sec")
405
406 if dependency_warnings > 0:
407 logger.info(f"Dependency parsing warnings: {dependency_warnings} sentences processed without dependencies")
408