Coverage for  / home / jenkins / .local / lib / python3.10 / site-packages / hyper_parallel / data / text / text_transform.py: 79%

131 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-11-15 07:18 +0800

1# Copyright 2026 Huawei Technologies Co., Ltd 

2# 

3# Licensed under the Apache License, Version 2.0 (the "License"); 

4# you may not use this file except in compliance with the License. 

5# You may obtain a copy of the License at 

6# 

7# http://www.apache.org/licenses/LICENSE-2.0 

8# 

9# Unless required by applicable law or agreed to in writing, software 

10# distributed under the License is distributed on an "AS IS" BASIS, 

11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 

12# See the License for the specific language governing permissions and 

13# limitations under the License. 

14# ============================================================================ 

15"""Transform plaintext and conversation records into model samples.""" 

16 

17from __future__ import annotations 

18 

19import json 

20from collections.abc import Callable, Mapping, Sequence 

21from dataclasses import dataclass 

22from typing import Any, Literal 

23 

24import torch 

25 

26from hyper_parallel.data.constants import IGNORE_INDEX 

27from hyper_parallel.data.dataset_logging import get_dataset_logger 

28from hyper_parallel.data.online.format import InstructionMessages, normalize_conversation 

29 

30TextDataType = Literal["plaintext", "sharegpt", "alpaca"] 

31logger = get_dataset_logger(__name__) 

32 

33 

34def _get_record_value(sample: Mapping[str, Any], keys: str | Sequence[str]) -> Any: 

35 if isinstance(keys, str): 

36 try: 

37 return sample[keys] 

38 except KeyError as exc: 

39 raise ValueError(f"Sample does not contain field {keys!r}") from exc 

40 for key in keys: 

41 if key in sample: 

42 return sample[key] 

43 raise ValueError(f"Sample does not contain any configured text fields: {list(keys)!r}") 

44 

45 

46def _normalize_tools(tools: Any, tools_key: str) -> list[dict[str, Any]] | None: 

47 """Normalize an explicitly configured tools field for native chat templates.""" 

48 if tools is None or tools is False: 

49 return None 

50 if isinstance(tools, str): 

51 if not tools: 

52 return None 

53 try: 

54 tools = json.loads(tools) 

55 except json.JSONDecodeError as exc: 

56 raise ValueError(f"Tools field {tools_key!r} must contain valid JSON") from exc 

57 if tools is None or tools is False: 

58 return None 

59 if not isinstance(tools, Sequence) or isinstance(tools, (str, bytes)): 

60 raise ValueError(f"Tools field {tools_key!r} must be a sequence of mappings") 

61 

62 normalized_tools = [] 

63 for tool in tools: 

64 if not isinstance(tool, Mapping): 

65 raise ValueError(f"Every entry in tools field {tools_key!r} must be a mapping") 

66 normalized_tools.append(dict(tool)) 

67 return normalized_tools or None 

68 

69 

70def _encode_messages( 

71 chat_template: Any, 

72 messages: Sequence[Mapping[str, Any]], 

73 max_seq_len: int, 

74 tools: Sequence[Mapping[str, Any]] | None = None, 

75) -> dict[str, Any]: 

76 """Encode normalized messages into a shifted causal-language-model sample.""" 

77 encode_kwargs = {"max_seq_len": max_seq_len} 

78 if tools: 

79 encode_kwargs["tools"] = tools 

80 encoded = chat_template.encode_messages(messages, **encode_kwargs) 

81 input_ids = torch.tensor(encoded["input_ids"], dtype=torch.int64) 

82 labels = torch.tensor(encoded["labels"], dtype=torch.int64) 

83 return { 

84 "input_ids": input_ids[:-1], 

85 "labels": labels[1:], 

86 } 

87 

88 

89class IdentityDataTransform: 

90 """Return each input sample unchanged.""" 

91 

92 def __init__(self, tokenizer: Any = None, chat_template: Any = None) -> None: 

93 """Retain optional upstream assets for target compatibility. 

94 

95 Args: 

96 tokenizer: Optional tokenizer built by the LLM Trainer. 

97 chat_template: Optional chat template built from model assets. 

98 """ 

99 self.tokenizer = tokenizer 

100 self.chat_template = chat_template 

101 

102 @staticmethod 

103 def __call__(sample: Any) -> Any: 

104 """Return the input sample without modification.""" 

105 return sample 

106 

107 

108@dataclass 

109class PlaintextTransform: 

110 """Tokenize plaintext records into one or more model samples.""" 

111 

112 tokenizer: Any 

113 max_seq_len: int 

114 text_keys: str | Sequence[str] = "text" 

115 drop_ragged_tail: bool = False 

116 

117 def __post_init__(self) -> None: 

118 """Validate the tokenizer and sequence length configuration.""" 

119 if self.tokenizer is None: 

120 raise ValueError("tokenizer is required for plaintext data") 

121 if self.max_seq_len <= 0: 

122 raise ValueError("max_seq_len must be positive") 

123 

124 def __call__(self, sample: Mapping[str, Any]) -> list[dict[str, Any]]: 

125 """Tokenize and chunk one plaintext record.""" 

126 text = _get_record_value(sample, self.text_keys) 

127 token_ids = self.tokenizer.encode(text, add_special_tokens=False) 

128 eos_token_id = getattr(self.tokenizer, "eos_token_id", None) 

129 if eos_token_id is not None: 

130 token_ids = [*token_ids, eos_token_id] 

131 

132 transformed = [] 

133 for start in range(0, len(token_ids) - 1, self.max_seq_len): 

134 window = token_ids[start:start + self.max_seq_len + 1] 

135 if (self.drop_ragged_tail and start > 0 

136 and len(window) < self.max_seq_len + 1): 

137 # Opt-in for models whose packed-sample boundaries must stay 

138 # aligned, such as the V4.1 CSA compression ratio and the 

139 # context-parallel shard size: a short tail window violates 

140 # both once it lands in a batch. Off by default, so every 

141 # other model keeps the trailing partial window. 

142 break 

143 text = torch.tensor(window, dtype=torch.long) 

144 model_sample = { 

145 "input_ids": text[:-1], 

146 "labels": text[1:], 

147 } 

148 transformed.append(model_sample) 

149 return transformed 

150 

151 def is_valid_sample(self, sample: Mapping[str, Any]) -> bool: 

152 """Return whether one source record contains non-empty plaintext.""" 

153 text = _get_record_value(sample, self.text_keys) 

154 if not isinstance(text, str): 

155 raise ValueError("Plaintext sample text must be a string") 

156 is_valid = text.strip() != "" 

157 return is_valid 

158 

159 

160@dataclass 

161class TextConversationTransform: 

162 """Encode conversation records with a configured chat template.""" 

163 

164 chat_template: Any 

165 max_seq_len: int 

166 text_keys: str | Sequence[str] = "conversation" 

167 tools_key: str | None = None 

168 message_formatter: Callable[[Mapping[str, Any]], list[dict[str, Any]]] | None = None 

169 

170 def __post_init__(self) -> None: 

171 """Validate the chat template and sequence length configuration.""" 

172 if self.chat_template is None: 

173 raise ValueError("chat_template is required for conversation data") 

174 if self.max_seq_len <= 0: 

175 raise ValueError("max_seq_len must be positive") 

176 if self.tools_key is not None and (not isinstance(self.tools_key, str) or not self.tools_key): 

177 raise ValueError("tools_key must be a non-empty string or None") 

178 

179 def __call__(self, sample: Mapping[str, Any]) -> list[dict[str, Any]]: 

180 """Encode one conversation record.""" 

181 messages = (self.message_formatter(sample) if self.message_formatter is not None 

182 else normalize_conversation(_get_record_value(sample, self.text_keys))) 

183 tools = None 

184 if self.tools_key is not None: 

185 tools = _normalize_tools(sample.get(self.tools_key), self.tools_key) 

186 encoded = _encode_messages(self.chat_template, messages, self.max_seq_len, tools) 

187 return [encoded] if bool(encoded["labels"].ne(IGNORE_INDEX).any()) else [] 

188 

189 def is_valid_sample(self, sample: Mapping[str, Any]) -> bool: 

190 """Return whether one source record contains conversation messages.""" 

191 messages = (self.message_formatter(sample) if self.message_formatter is not None 

192 else _get_record_value(sample, self.text_keys)) 

193 is_valid = bool(messages) 

194 return is_valid 

195 

196 

197def build_text_transform( 

198 data_type: TextDataType, 

199 *, 

200 tokenizer: Any = None, 

201 chat_template: Any = None, 

202 max_seq_len: int, 

203 text_keys: str | Sequence[str] | None = None, 

204 drop_ragged_tail: bool = False, 

205 tools_key: str | None = None, 

206 column_mapping: Mapping[str, str] | None = None, 

207 input_separator: str = "\n\n", 

208) -> Callable[[Any], Any]: 

209 """Build the transform selected by the text dataset format. 

210 

211 Args: 

212 data_type: One of plaintext, sharegpt, or alpaca. 

213 tokenizer: Tokenizer used by plaintext transforms. 

214 chat_template: Chat template used by conversation transforms. 

215 max_seq_len: Maximum model sequence length. 

216 text_keys: Source field(s); defaults to text or conversations by format. 

217 drop_ragged_tail: Drop trailing partial plaintext windows. 

218 tools_key: Optional field containing tool definitions for conversation data. 

219 column_mapping: Logical instruction/input/output fields mapped to source columns. 

220 input_separator: Separator inserted between non-empty instruction and input fields. 

221 

222 Returns: 

223 The configured text sample transform. 

224 

225 Raises: 

226 ValueError: If ``data_type`` is unsupported. 

227 """ 

228 if text_keys is None: 

229 text_keys = {"sharegpt": "conversations"}.get(data_type, "text") 

230 if data_type == "plaintext": 

231 if tools_key is not None: 

232 raise ValueError("tools_key is only supported for sharegpt data") 

233 data_transform = PlaintextTransform(tokenizer, max_seq_len, text_keys, drop_ragged_tail) 

234 elif data_type == "sharegpt": 

235 data_transform = TextConversationTransform(chat_template, max_seq_len, text_keys, tools_key) 

236 elif data_type == "alpaca": 

237 if tools_key is not None: 

238 raise ValueError("tools_key is only supported for sharegpt data") 

239 if column_mapping is None: 

240 raise ValueError("column_mapping is required for alpaca data") 

241 data_transform = TextConversationTransform( 

242 chat_template, 

243 max_seq_len, 

244 message_formatter=InstructionMessages(column_mapping, input_separator), 

245 ) 

246 else: 

247 raise ValueError(f"Unsupported text data format: {data_type!r}") 

248 

249 logger.debug("Built text transform: data_type=%s, transform=%s", data_type, type(data_transform).__name__) 

250 return data_transform