vlab_prepro 0.4.1__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.
vlab_prepro/__init__.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from toolz import curry
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PreprocessingError(BaseException):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def wrap_empty(fn):
|
|
15
|
+
def _wrapper(df, *args, **kwargs):
|
|
16
|
+
if df.shape[0] == 0:
|
|
17
|
+
return None
|
|
18
|
+
return fn(df, *args, **kwargs)
|
|
19
|
+
|
|
20
|
+
return _wrapper
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _flatten_dict(col, r, prefix):
|
|
24
|
+
for k, v in json.loads(r[col]).items():
|
|
25
|
+
if prefix is None:
|
|
26
|
+
r[k] = v
|
|
27
|
+
else:
|
|
28
|
+
r[f"{prefix}_{k}"] = v
|
|
29
|
+
return r
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def flatten_dict(col, df, prefix=None):
|
|
33
|
+
return df.apply(lambda r: _flatten_dict(col, r, prefix), 1).drop(columns=[col])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _new_cols(left, right):
|
|
37
|
+
left = {k for k in left.columns}
|
|
38
|
+
return {k for k in right.columns if k not in left}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _add_duration(df):
|
|
42
|
+
df = df.sort_values("timestamp")
|
|
43
|
+
df["survey_start_time"] = df.timestamp.iloc[0]
|
|
44
|
+
df["survey_end_time"] = df.timestamp.iloc[-1]
|
|
45
|
+
df["survey_duration"] = (
|
|
46
|
+
df.timestamp.iloc[-1] - df.timestamp.iloc[0]
|
|
47
|
+
).total_seconds()
|
|
48
|
+
|
|
49
|
+
time_to_answer = df.timestamp.diff().dt.total_seconds()
|
|
50
|
+
df["answer_time_min"] = time_to_answer.min()
|
|
51
|
+
df["answer_time_median"] = time_to_answer.quantile(0.5)
|
|
52
|
+
df["answer_time_75"] = time_to_answer.quantile(0.75)
|
|
53
|
+
df["answer_time_90"] = time_to_answer.quantile(0.90)
|
|
54
|
+
|
|
55
|
+
return df
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def add_final_answer(df):
|
|
59
|
+
df = df.sort_values("timestamp")
|
|
60
|
+
df["final_answer"] = True
|
|
61
|
+
df.loc[
|
|
62
|
+
df.duplicated(["userid", "surveyid", "question_idx"], keep="last"),
|
|
63
|
+
"final_answer",
|
|
64
|
+
] = False
|
|
65
|
+
return df
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@curry
|
|
69
|
+
def _assign(name, fn, tindex, d):
|
|
70
|
+
return d.assign(**{name: fn(d.index[0])})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _add_time_indicators(indicators, df):
|
|
74
|
+
tindex = "survey_start_time"
|
|
75
|
+
|
|
76
|
+
if tindex not in df.columns:
|
|
77
|
+
raise KeyError(f"Could no find time index: {tindex} in dataframe columns")
|
|
78
|
+
|
|
79
|
+
for name, frame, fn in indicators:
|
|
80
|
+
df = (
|
|
81
|
+
df.set_index(tindex)
|
|
82
|
+
.resample(frame)
|
|
83
|
+
.apply(wrap_empty(_assign(name, fn, tindex)))
|
|
84
|
+
.reset_index()
|
|
85
|
+
)
|
|
86
|
+
return df
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def drop_duplicated_users(form_keys, df):
|
|
90
|
+
# form_keys should uniquely identify your form
|
|
91
|
+
# (i.e. shortcode! Or, if there are multiple shortcodes that shouldn't
|
|
92
|
+
# be taken twice, some other metadata that the forms have to identify them)
|
|
93
|
+
|
|
94
|
+
# multiple flowids means user came back and took form again
|
|
95
|
+
keys = ["userid"] + list(form_keys)
|
|
96
|
+
duplicated_users = (
|
|
97
|
+
df.groupby(keys)
|
|
98
|
+
.filter(lambda df: df.flowid.unique().shape[0] > 1)
|
|
99
|
+
.userid.unique()
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
logging.warning(f"Removing {len(duplicated_users)} users for duplication.")
|
|
103
|
+
|
|
104
|
+
return df[~df.userid.isin(duplicated_users)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_number(s):
|
|
108
|
+
"""Follows similar validation rules to the chatbot number validator
|
|
109
|
+
|
|
110
|
+
Note: these rules are pretty loose and maybe result in silly numbers.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
s = re.sub(",", "", s)
|
|
115
|
+
s = re.sub(r"\.", "", s)
|
|
116
|
+
s = s.strip()
|
|
117
|
+
return int(s)
|
|
118
|
+
except TypeError:
|
|
119
|
+
return s
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def hash_int(i):
|
|
125
|
+
b = str(i).encode("ASCII")
|
|
126
|
+
h = hashlib.sha256()
|
|
127
|
+
h.update(b)
|
|
128
|
+
return h.hexdigest()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Preprocessor:
|
|
132
|
+
def __init__(self):
|
|
133
|
+
self.keys = {"userid"}
|
|
134
|
+
self.form_df = None
|
|
135
|
+
|
|
136
|
+
@curry
|
|
137
|
+
def add_form_data(self, form_df, df, prefix=None):
|
|
138
|
+
new_form_df = flatten_dict("metadata", form_df, prefix)
|
|
139
|
+
self.form_df = new_form_df
|
|
140
|
+
self.keys = self.keys | set(new_form_df.columns)
|
|
141
|
+
return df.merge(new_form_df, on="surveyid")
|
|
142
|
+
|
|
143
|
+
@curry
|
|
144
|
+
def remove_form_data(self, df):
|
|
145
|
+
if self.form_df is None:
|
|
146
|
+
raise PreprocessingError("No form data to remove from the dataframe.")
|
|
147
|
+
|
|
148
|
+
df = df.drop(self.form_df.columns, axis=1)
|
|
149
|
+
self.keys = self.keys - set(self.form_df.columns)
|
|
150
|
+
self.form_df = None
|
|
151
|
+
return df
|
|
152
|
+
|
|
153
|
+
@curry
|
|
154
|
+
def add_metadata(self, keys, df):
|
|
155
|
+
question_refs = df.question_ref.unique()
|
|
156
|
+
for key in keys:
|
|
157
|
+
if key in question_refs:
|
|
158
|
+
raise PreprocessingError(
|
|
159
|
+
f"Trying to add metadata that already exists as a column: {key}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
df[key] = df.metadata.map(lambda x: json.loads(x).get(key))
|
|
163
|
+
self.keys.add(key)
|
|
164
|
+
return df
|
|
165
|
+
|
|
166
|
+
@curry
|
|
167
|
+
def parse_timestamp(self, df):
|
|
168
|
+
# NOTE: If ISO has TZ info, then it will be TZ aware, otherwise
|
|
169
|
+
# it will be TZ naive.
|
|
170
|
+
return df.assign(timestamp=df.timestamp.map(lambda x: pd.Timestamp(x)))
|
|
171
|
+
|
|
172
|
+
@curry
|
|
173
|
+
def add_duration(self, df):
|
|
174
|
+
if not pd.api.types.is_datetime64_dtype(df.timestamp):
|
|
175
|
+
df = self.parse_timestamp(df)
|
|
176
|
+
|
|
177
|
+
df = (
|
|
178
|
+
df.groupby(list(self.keys), dropna=False)
|
|
179
|
+
.apply(_add_duration)
|
|
180
|
+
.reset_index(drop=True)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
self.keys = self.keys | {
|
|
184
|
+
"survey_start_time",
|
|
185
|
+
"survey_end_time",
|
|
186
|
+
"survey_duration",
|
|
187
|
+
"answer_time_min",
|
|
188
|
+
"answer_time_median",
|
|
189
|
+
"answer_time_75",
|
|
190
|
+
"answer_time_90",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return df
|
|
194
|
+
|
|
195
|
+
@curry
|
|
196
|
+
def add_time_indicators(self, indicators, df):
|
|
197
|
+
if not pd.api.types.is_datetime64_dtype(df.timestamp):
|
|
198
|
+
df = self.parse_timestamp(df)
|
|
199
|
+
|
|
200
|
+
lookup = {
|
|
201
|
+
"week": ("week", "1w", lambda i: i.week),
|
|
202
|
+
"month": ("month", "1m", lambda i: i.month),
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
inds = [lookup[i] for i in indicators]
|
|
206
|
+
|
|
207
|
+
for i in indicators:
|
|
208
|
+
self.keys.add(i)
|
|
209
|
+
|
|
210
|
+
return _add_time_indicators(inds, df)
|
|
211
|
+
|
|
212
|
+
@curry
|
|
213
|
+
def add_final_answer(self, df):
|
|
214
|
+
return add_final_answer(df)
|
|
215
|
+
|
|
216
|
+
@curry
|
|
217
|
+
def keep_final_answer(self, df):
|
|
218
|
+
if "final_answer" not in df.columns:
|
|
219
|
+
df = self.add_final_answer(df)
|
|
220
|
+
|
|
221
|
+
return df[df.final_answer].reset_index(drop=True)
|
|
222
|
+
|
|
223
|
+
@curry
|
|
224
|
+
def count_invalid(self, df):
|
|
225
|
+
if "final_answer" not in df.columns:
|
|
226
|
+
df = self.add_final_answer(df)
|
|
227
|
+
|
|
228
|
+
df = (
|
|
229
|
+
df.groupby("userid")
|
|
230
|
+
.apply(
|
|
231
|
+
lambda df: df.assign(
|
|
232
|
+
invalid_answer_percentage=(~df.final_answer).mean(),
|
|
233
|
+
invalid_answer_count=df.final_answer.count()
|
|
234
|
+
- df.final_answer.sum(),
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
.reset_index(drop=True)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
self.keys.add("invalid_answer_percentage")
|
|
241
|
+
self.keys.add("invalid_answer_count")
|
|
242
|
+
|
|
243
|
+
return df
|
|
244
|
+
|
|
245
|
+
@curry
|
|
246
|
+
def drop_users_without(self, metadata_key, df):
|
|
247
|
+
"""Used to drop testers"""
|
|
248
|
+
|
|
249
|
+
if metadata_key not in df.columns:
|
|
250
|
+
raise PreprocessingError(
|
|
251
|
+
f"Dataframe does not have column {metadata_key}."
|
|
252
|
+
" Maybe consider running add_metadata first?"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
testers = df[df[metadata_key].isna()].userid.unique()
|
|
256
|
+
df = df[~df.userid.isin(testers)].reset_index(drop=True)
|
|
257
|
+
|
|
258
|
+
logging.warning(
|
|
259
|
+
f"Removing {len(testers)} users who answered a survey without"
|
|
260
|
+
f" a value for {metadata_key} in the metadata"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return df
|
|
264
|
+
|
|
265
|
+
@curry
|
|
266
|
+
def drop_duplicated_users(self, form_keys, df):
|
|
267
|
+
return drop_duplicated_users(form_keys, df)
|
|
268
|
+
|
|
269
|
+
@curry
|
|
270
|
+
def pivot(self, answer_column, df):
|
|
271
|
+
keys = self.keys
|
|
272
|
+
|
|
273
|
+
if "surveyid" not in keys:
|
|
274
|
+
logging.warning(
|
|
275
|
+
"Pivoting without survey information. "
|
|
276
|
+
"Make sure question_refs are unique across surveys "
|
|
277
|
+
"as all surveys will be collapsed"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
return (
|
|
282
|
+
df.pivot(index=keys, columns="question_ref", values=answer_column)
|
|
283
|
+
.reset_index()
|
|
284
|
+
.sort_values(["userid"])
|
|
285
|
+
)
|
|
286
|
+
except ValueError as e:
|
|
287
|
+
raise PreprocessingError(
|
|
288
|
+
"Could not pivot. Potentially you should use add_form_data "
|
|
289
|
+
"to add surveyid and ensure that each user/survey is a unique line "
|
|
290
|
+
"or remove duplicated questions or duplicated users"
|
|
291
|
+
) from e
|
|
292
|
+
|
|
293
|
+
@curry
|
|
294
|
+
def map_columns(self, cols, fn, df):
|
|
295
|
+
return df.assign(**{col: df[col].map(fn) for col in cols})
|
|
296
|
+
|
|
297
|
+
@curry
|
|
298
|
+
def hash_userid(self, df):
|
|
299
|
+
return df.assign(userid=df.userid.map(hash_int))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 The World Bank Group and vlab-prepro contributors
|
|
4
|
+
(https://github.com/vlab-research/vlab-prepro/graph/contributors)
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: vlab_prepro
|
|
3
|
+
Version: 0.4.1
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Nandan Rao
|
|
6
|
+
Author-email: nandanmarkrao@gmail.com
|
|
7
|
+
Requires-Python: >=3.9,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Dist: pandas (>=2.0.3,<3.0.0)
|
|
14
|
+
Requires-Dist: toolz (>=0.11.1,<0.12.0)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
vlab_prepro/__init__.py,sha256=3bVELsp6OUbVKmx75809Bcf-ndc-u-zzQu7dNPAW1X4,160
|
|
2
|
+
vlab_prepro/preprocess.py,sha256=x6LWl6IAo5lSeYLNpdUk9KWq0J1UXfJuJzERnVWUbjQ,8330
|
|
3
|
+
vlab_prepro-0.4.1.dist-info/LICENSE,sha256=pNn7sV8cyHqDuwlSJyc8tj4-DLCzA8IPeQ4IjVuRwHo,1172
|
|
4
|
+
vlab_prepro-0.4.1.dist-info/METADATA,sha256=lus5xzc3d0a-1MRGSt7QgfaQSIaDDrGOysd3LXfXMNk,480
|
|
5
|
+
vlab_prepro-0.4.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
6
|
+
vlab_prepro-0.4.1.dist-info/RECORD,,
|