chugr 0.2.0.dev0__py3-none-any.whl

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.
Files changed (53) hide show
  1. chugr/__init__.py +46 -0
  2. chugr/app/test.py +105 -0
  3. chugr/common/__init__.py +9 -0
  4. chugr/common/cli.py +234 -0
  5. chugr/common/collate.py +104 -0
  6. chugr/common/config.py +335 -0
  7. chugr/common/random.py +76 -0
  8. chugr/common/serialize.py +150 -0
  9. chugr/common/task_config.py +24 -0
  10. chugr/common/types.py +122 -0
  11. chugr/common/urls.py +88 -0
  12. chugr/doc/__init__.py +17 -0
  13. chugr/doc/constants.py +15 -0
  14. chugr/doc/doc_processor.py +248 -0
  15. chugr/doc/doc_read_processor.py +249 -0
  16. chugr/doc/doc_vqa_processor.py +113 -0
  17. chugr/doc/synth_text.py +212 -0
  18. chugr/hfds/__init__.py +2 -0
  19. chugr/hfds/collate.py +83 -0
  20. chugr/hfds/loader.py +385 -0
  21. chugr/hfds/wrappers.py +108 -0
  22. chugr/image/__init__.py +4 -0
  23. chugr/image/build_transforms_doc.py +427 -0
  24. chugr/image/build_transforms_image.py +203 -0
  25. chugr/image/transforms_alb.py +112 -0
  26. chugr/image/transforms_factory.py +58 -0
  27. chugr/image/transforms_torch.py +137 -0
  28. chugr/loader.py +249 -0
  29. chugr/naflex.py +357 -0
  30. chugr/task_pipeline/__init__.py +7 -0
  31. chugr/task_pipeline/pipeline_doc_read.py +66 -0
  32. chugr/task_pipeline/pipeline_doc_vqa.py +124 -0
  33. chugr/task_pipeline/pipeline_factory.py +32 -0
  34. chugr/task_pipeline/pipeline_gtparse.py +93 -0
  35. chugr/task_pipeline/pipeline_image_text.py +90 -0
  36. chugr/task_pipeline/pipeline_manual.py +27 -0
  37. chugr/text/__init__.py +1 -0
  38. chugr/text/tokenization.py +222 -0
  39. chugr/version.py +1 -0
  40. chugr/wds/__init__.py +7 -0
  41. chugr/wds/dataset_info.py +65 -0
  42. chugr/wds/decode.py +319 -0
  43. chugr/wds/filters.py +142 -0
  44. chugr/wds/helpers.py +68 -0
  45. chugr/wds/loader.py +187 -0
  46. chugr/wds/pipeline.py +182 -0
  47. chugr/wds/shardlists.py +134 -0
  48. chugr/wds/tariterators.py +70 -0
  49. chugr-0.2.0.dev0.dist-info/METADATA +436 -0
  50. chugr-0.2.0.dev0.dist-info/RECORD +53 -0
  51. chugr-0.2.0.dev0.dist-info/WHEEL +4 -0
  52. chugr-0.2.0.dev0.dist-info/entry_points.txt +4 -0
  53. chugr-0.2.0.dev0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,90 @@
1
+ from dataclasses import dataclass
2
+ from functools import partial
3
+ from typing import Callable, Optional, Union
4
+
5
+ import webdataset as wds
6
+
7
+ from chugr.common import DataTaskCfg, ImageFeatureInfo, FeatureInfo
8
+ from chugr.wds import get_error_handler, create_image_decoder
9
+
10
+ _DEFAULT_IMG_KEY = "jpg;png;jpeg;webp;tif"
11
+ _DEFAULT_TXT_KEY = "txt"
12
+ _DEFAULT_IMG_KEY_TUPLE = tuple(_DEFAULT_IMG_KEY.split(';'))
13
+ _DEFAULT_TXT_KEY_TUPLE = tuple(_DEFAULT_TXT_KEY.split(';'))
14
+
15
+
16
+ @dataclass
17
+ class DataTaskImageTextCfg(DataTaskCfg):
18
+ image_input_feat: ImageFeatureInfo = ImageFeatureInfo()
19
+ text_input_feat: FeatureInfo = FeatureInfo('text', input_key=_DEFAULT_TXT_KEY)
20
+
21
+
22
+ def filter_incomplete(
23
+ sample,
24
+ image_key=_DEFAULT_IMG_KEY_TUPLE,
25
+ text_key=_DEFAULT_TXT_KEY_TUPLE
26
+ ):
27
+ has_caption = any(k in sample for k in text_key)
28
+ has_image = any(k in sample for k in image_key)
29
+ return has_caption and has_image
30
+
31
+
32
+ def build_task_pipeline_image_text(
33
+ cfg: DataTaskImageTextCfg,
34
+ ):
35
+ """ Create pipeline for dual image & text input pipelines.
36
+ """
37
+ handler = get_error_handler(cfg.error_handler)
38
+ pipe = []
39
+
40
+ # FIXME add support for caption target for caption tasks or use a separate pipe?
41
+
42
+ if cfg.filter_valid:
43
+ filter_fn = partial(
44
+ filter_incomplete,
45
+ image_key=tuple(cfg.image_input_feat.input_key.split(';')),
46
+ text_key=tuple(cfg.text_input_feat.input_key.split(';')),
47
+ )
48
+ pipe += [
49
+ wds.select(filter_fn)
50
+ ]
51
+
52
+ if cfg.decode_and_process_fn:
53
+ pipe += [
54
+ wds.map(cfg.decode_and_process_fn)
55
+ ]
56
+ else:
57
+ decode_fn = create_image_decoder(
58
+ cfg.decode_fn,
59
+ image_mode=cfg.image_input_feat.image_mode,
60
+ handler=handler,
61
+ )
62
+
63
+ rename_dict = {
64
+ cfg.image_input_feat.output_name: cfg.image_input_feat.input_key,
65
+ cfg.text_input_feat.output_name: cfg.text_input_feat.input_key,
66
+ }
67
+ pipe += [
68
+ decode_fn,
69
+ wds.rename(**rename_dict, keep=False, handler=handler),
70
+ ]
71
+
72
+ map_dict = {}
73
+ if cfg.image_process_fn is not None:
74
+ map_dict[cfg.image_input_feat.output_name] = cfg.image_process_fn
75
+ if cfg.text_process_fn is not None:
76
+ map_dict[cfg.text_input_feat.output_name] = cfg.text_process_fn
77
+ if map_dict:
78
+ pipe += [
79
+ wds.map_dict(**map_dict, handler=handler)
80
+ ]
81
+
82
+ if cfg.output_tuple:
83
+ pipe += [
84
+ wds.to_tuple(
85
+ cfg.image_input_feat.output_name,
86
+ cfg.text_input_feat.output_name,
87
+ )
88
+ ]
89
+
90
+ return pipe
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+ from typing import Callable
3
+
4
+ import webdataset as wds
5
+
6
+ from chugr.common import DataTaskCfg, FeatureInfo, ImageFeatureInfo
7
+ from chugr.doc import DocReadProcessor
8
+ from chugr.wds.helpers import log_and_continue
9
+
10
+
11
+ @dataclass
12
+ class DataTaskManualCfg(DataTaskCfg):
13
+ pass
14
+
15
+
16
+ def build_task_pipeline_manual(
17
+ cfg: DataTaskManualCfg,
18
+ ):
19
+ assert cfg.decode_and_process_fn is not None
20
+ # a pipeline that relies fully on passed in decode_and_process_fn, other cfg fields ignored
21
+ pipe = [
22
+ wds.map(
23
+ cfg.decode_and_process_fn,
24
+ handler=log_and_continue,
25
+ )
26
+ ]
27
+ return pipe
chugr/text/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .tokenization import tokenize, text_input_to_target, prepare_text_input, create_text_preprocessor
@@ -0,0 +1,222 @@
1
+ import logging
2
+ from functools import partial
3
+ from typing import Callable, List, Optional, Union
4
+
5
+ import torch
6
+
7
+ _logger = logging.getLogger(__name__)
8
+ _num_prompts_truncated = 0
9
+
10
+
11
+ def prompt_end_pos(tokens: torch.Tensor, prompt_end_token_id: int, empty_default: int = 0) -> int:
12
+ end_pos = torch.nonzero(tokens == prompt_end_token_id)
13
+ return end_pos[-1].item() if end_pos.numel() > 0 else empty_default
14
+
15
+
16
+ def text_input_to_target(
17
+ text_input_ids: torch.Tensor,
18
+ tokenizer: Optional[Callable] = None,
19
+ prompt_end_token: Optional[Union[str, int]] = None,
20
+ ignore_id: int = -100,
21
+ pad_token_id: Optional[int] = None,
22
+ ):
23
+ target = text_input_ids.clone()
24
+
25
+ if pad_token_id is None:
26
+ assert tokenizer is not None, 'tokenizer must be specified if pad_token_id is not.'
27
+ pad_token_id = tokenizer.pad_token_id
28
+
29
+ # model doesn't need to predict pad token
30
+ target[target == pad_token_id] = ignore_id
31
+
32
+ if prompt_end_token is not None:
33
+ if isinstance(prompt_end_token, str):
34
+ assert tokenizer is not None, 'tokenizer must be specified if prompt_end_token_id required.'
35
+ prompt_end_token_id = tokenizer.convert_tokens_to_ids(prompt_end_token)
36
+ else:
37
+ prompt_end_token_id = prompt_end_token # already an int
38
+
39
+ # model doesn't need to predict prompt (for VQA)
40
+ end_pos = prompt_end_pos(target, prompt_end_token_id, empty_default=-1)
41
+ if end_pos < 0:
42
+ # prompt end truncated away (prompt longer than max_length), nothing left to predict
43
+ target[:] = ignore_id
44
+ else:
45
+ target[:end_pos + 1] = ignore_id
46
+
47
+ return target
48
+
49
+
50
+ def tokenize(
51
+ text: str,
52
+ tokenizer: Callable,
53
+ max_length: int,
54
+ ids_only: bool = True,
55
+ ):
56
+ output = tokenizer(
57
+ text,
58
+ add_special_tokens=False,
59
+ return_tensors="pt",
60
+ max_length=max_length,
61
+ padding="max_length",
62
+ truncation=True,
63
+ )
64
+ if ids_only:
65
+ return output.input_ids[0]
66
+ return output
67
+
68
+
69
+ def truncate_prompt(
70
+ token_ids: List[int],
71
+ max_length: int,
72
+ prompt_end_token_id: int,
73
+ special_token_ids=(),
74
+ ) -> List[int]:
75
+ """ Shorten the prompt of an over-long `prompt + target` token sequence so the prompt end token and the target
76
+ still fit in `max_length`.
77
+
78
+ Tokens are removed from the end of the prompt body, the prompt's leading special tokens (task start, question
79
+ prefix) and trailing special tokens (question suffix, prompt end) are kept. Returns the ids unchanged if they
80
+ fit or there is no prompt end token, and may still return more than `max_length` tokens if the target alone
81
+ is too long (the caller truncates the tail).
82
+ """
83
+ over = len(token_ids) - max_length
84
+ ends = [i for i, t in enumerate(token_ids) if t == prompt_end_token_id]
85
+ if over <= 0 or not ends:
86
+ return token_ids
87
+ special = set(special_token_ids) | {prompt_end_token_id}
88
+ body_end = ends[-1]
89
+ while body_end > 0 and token_ids[body_end - 1] in special:
90
+ body_end -= 1
91
+ body_start = 0
92
+ while body_start < body_end and token_ids[body_start] in special:
93
+ body_start += 1
94
+ cut = min(over, body_end - body_start)
95
+ if cut <= 0:
96
+ return token_ids
97
+ return token_ids[:body_end - cut] + token_ids[body_end:]
98
+
99
+
100
+ def special_token_ids(tokenizer) -> set:
101
+ """ Special & added token ids, for HF tokenizers and wrappers w/ a subset of the interface. """
102
+ ids = set(getattr(tokenizer, 'all_special_ids', None) or ())
103
+ get_added_vocab = getattr(tokenizer, 'get_added_vocab', None)
104
+ if get_added_vocab is not None:
105
+ ids |= set(get_added_vocab().values())
106
+ return ids
107
+
108
+
109
+ def _tokenize_keep_prompt_end(
110
+ text: str,
111
+ text_input_ids: torch.Tensor,
112
+ tokenizer: Callable,
113
+ max_length: int,
114
+ prompt_end_token: Union[str, int],
115
+ ) -> torch.Tensor:
116
+ """ Re-tokenize a (possibly) truncated `text_input_ids` w/ the prompt shortened instead of the tail cut off. """
117
+ global _num_prompts_truncated
118
+ pad_token_id = tokenizer.pad_token_id
119
+ if bool((text_input_ids == pad_token_id).any()):
120
+ return text_input_ids # padded, nothing was truncated
121
+ prompt_end_token_id = prompt_end_token
122
+ if isinstance(prompt_end_token, str):
123
+ prompt_end_token_id = tokenizer.convert_tokens_to_ids(prompt_end_token)
124
+ special = special_token_ids(tokenizer)
125
+ ends = torch.nonzero(text_input_ids == prompt_end_token_id)
126
+ if len(ends) and all(int(t) in special for t in text_input_ids[:int(ends[0])]):
127
+ # the prompt end survived truncation w/ only special tokens before it (e.g. a task start token as the
128
+ # prompt end), no prompt body to shorten, skip re-tokenizing
129
+ return text_input_ids
130
+ full_ids = tokenizer(text, add_special_tokens=False).input_ids
131
+ if len(full_ids) <= max_length:
132
+ return text_input_ids
133
+ ids = truncate_prompt(full_ids, max_length, prompt_end_token_id, special)
134
+ if len(ids) == len(full_ids):
135
+ return text_input_ids # no prompt (body) to shorten
136
+ _num_prompts_truncated += 1
137
+ n = _num_prompts_truncated
138
+ if n == 10 ** (len(str(n)) - 1): # log the 1st, 10th, 100th, ... (per process / dataloader worker)
139
+ _logger.warning(
140
+ f'Prompt shortened by {len(full_ids) - len(ids)} tokens to fit the target in max_length={max_length} '
141
+ f'({n} prompts shortened so far in this process).')
142
+ ids = ids[:max_length] # target alone too long, the tail is still cut
143
+ num_pad = max_length - len(ids)
144
+ if num_pad:
145
+ pad = [pad_token_id] * num_pad
146
+ ids = pad + ids if getattr(tokenizer, 'padding_side', 'right') == 'left' else ids + pad
147
+ return torch.tensor(ids, dtype=text_input_ids.dtype)
148
+
149
+
150
+ def prepare_text_input(
151
+ text_input,
152
+ tokenizer: Callable,
153
+ max_length: int,
154
+ task_start_token: Optional[str] = None,
155
+ prompt_end_token: Optional[str] = None,
156
+ add_eos_token: bool = True,
157
+ ignore_id: int = -100,
158
+ include_target: bool = True,
159
+ return_dict: bool = True,
160
+ input_key: str = "text_input",
161
+ target_key: str = "text_target",
162
+ ):
163
+ """
164
+ Simple data preprocessing for raw-text data.
165
+ """
166
+ if task_start_token:
167
+ text_input = task_start_token + text_input
168
+
169
+ if add_eos_token:
170
+ text_input += tokenizer.eos_token
171
+
172
+ text_input_ids = tokenize(text_input, tokenizer=tokenizer, max_length=max_length)
173
+ if prompt_end_token is not None:
174
+ # an over-long prompt (question) is shortened so the prompt end token & target (answer) aren't truncated away
175
+ text_input_ids = _tokenize_keep_prompt_end(text_input, text_input_ids, tokenizer, max_length, prompt_end_token)
176
+
177
+ if include_target:
178
+ text_target_ids = text_input_to_target(text_input_ids, tokenizer, prompt_end_token, ignore_id)
179
+ if return_dict:
180
+ return {input_key: text_input_ids, target_key: text_target_ids}
181
+ else:
182
+ return text_input_ids, text_target_ids
183
+ else:
184
+ # FIXME calculate prompt end pos for validation use (target not needed)
185
+ if return_dict:
186
+ return {input_key: text_input_ids}
187
+ else:
188
+ return text_input_ids
189
+
190
+
191
+ def create_text_preprocessor(
192
+ tokenizer: Union[str, Callable],
193
+ max_length: int = 1024,
194
+ task_start_token: Optional[str] = None,
195
+ prompt_end_token: Optional[str] = None,
196
+ ignore_id: int = -100,
197
+ include_target: bool = True,
198
+ return_dict: bool = True,
199
+ input_key: str = "text_input",
200
+ target_key: str = "text_target",
201
+ ):
202
+ if isinstance(tokenizer, str):
203
+ import transformers
204
+ tokenizer = transformers.AutoTokenizer.from_pretrained(tokenizer)
205
+
206
+ # FIXME just binding prepare_text_input fn for now
207
+ # This is currently tailored to the Donut style image enc + text decoder use case.
208
+ # More functionality / variety required.
209
+
210
+ preprocess_fn = partial(
211
+ prepare_text_input,
212
+ tokenizer=tokenizer,
213
+ max_length=max_length,
214
+ task_start_token=task_start_token,
215
+ prompt_end_token=prompt_end_token,
216
+ ignore_id=ignore_id,
217
+ include_target=include_target,
218
+ return_dict=return_dict,
219
+ input_key=input_key,
220
+ target_key=target_key,
221
+ )
222
+ return preprocess_fn
chugr/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '0.2.0dev0'
chugr/wds/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .decode import decode_pdf_pages, decode_image_pages, create_image_decoder, DecodeDoc
2
+ from .filters import detshuffle_v2, map_v2, map_expand_maybe, map_expand_always, flatten_nested
3
+ from .helpers import log_and_continue, expand_urls, get_error_handler
4
+ from .loader import create_loader_wds
5
+ from .pipeline import build_data_pipeline
6
+ from .shardlists import ResampledShardsV2, ShuffledShardList
7
+ from .tariterators import group_by_keys_nothrow, tarfile_to_samples_nothrow
@@ -0,0 +1,65 @@
1
+ import ast
2
+ import json
3
+ import os
4
+ from typing import Dict
5
+
6
+ from webdataset.shardlists import expand_urls
7
+
8
+ from chugr.common import SplitInfo
9
+
10
+
11
+ def get_dataset_size(shards):
12
+ shardlist, _ = expand_urls(shards)
13
+ dir_path = os.path.dirname(shardlist[0])
14
+
15
+ sizes_filename = os.path.join(dir_path, 'sizes.json')
16
+ len_filename = os.path.join(dir_path, '__len__')
17
+
18
+ if os.path.exists(sizes_filename):
19
+ sizes = json.load(open(sizes_filename, 'r'))
20
+ total_size = sum([int(sizes[os.path.basename(shard)]) for shard in shardlist])
21
+ elif os.path.exists(len_filename):
22
+ total_size = ast.literal_eval(open(len_filename, 'r').read())
23
+ else:
24
+ total_size = None # num samples undefined
25
+
26
+ num_shards = len(shardlist)
27
+
28
+ return total_size, num_shards
29
+
30
+ ## FIXME this is not working / not completed, parsing _info files is a TODO
31
+
32
+ def _parse_split_info(split: str, info: Dict):
33
+ def _info_convert(dict_info):
34
+ return SplitInfo(
35
+ num_samples=dict_info['num_samples'],
36
+ filenames=tuple(dict_info['filenames']),
37
+ shard_lengths=tuple(dict_info['shard_lengths']),
38
+ name=dict_info['name'],
39
+ )
40
+
41
+ if 'tar' in split or '..' in split:
42
+ split_filenames = expand_urls(split)
43
+ if split_name:
44
+ split_info = info['splits'][split_name]
45
+ if not num_samples:
46
+ _fc = {f: c for f, c in zip(split_info['filenames'], split_info['shard_lengths'])}
47
+ num_samples = sum(_fc[f] for f in split_filenames)
48
+ split_info['filenames'] = tuple(_fc.keys())
49
+ split_info['shard_lengths'] = tuple(_fc.values())
50
+ split_info['num_samples'] = num_samples
51
+ split_info = _info_convert(split_info)
52
+ else:
53
+ split_info = SplitInfo(
54
+ name=split_name,
55
+ num_samples=num_samples,
56
+ filenames=split_filenames,
57
+ )
58
+ else:
59
+ if 'splits' not in info or split not in info['splits']:
60
+ raise RuntimeError(f"split {split} not found in info ({info.get('splits', {}).keys()})")
61
+ split = split
62
+ split_info = info['splits'][split]
63
+ split_info = _info_convert(split_info)
64
+
65
+ return split_info