Download parallel/src_snapshot/v3/features.py from Eric-Yyvuw/yeepee-ctr: direct link, hf CLI and curl.
- Browser
- Download file 18.6 kB
-
https://huggingface.co/Eric-Yyvuw/yeepee-ctr/resolve/main/parallel/src_snapshot/v3/features.py
- Command line
-
hf download hf://Eric-Yyvuw/yeepee-ctr/parallel/src_snapshot/v3/features.py
-
curl -L -o features.py https://huggingface.co/Eric-Yyvuw/yeepee-ctr/resolve/main/parallel/src_snapshot/v3/features.py
18.6 kB
| """Feature builder (as-of-cutoff, leakage-safe) for TechArena CTR. | |
| build(C, T_ev, T_rows, TE, TR) returns one feature row per target candidate. | |
| Every statistic uses ONLY: | |
| * train events/rows with ts < C (fit window: impressions + labels), and | |
| * click histories of events with ts < C, plus the target event's OWN history (strictly before its ts). | |
| Since target events are each user's first event at/after C, their histories contain only clicks < C, | |
| so nothing observed after a target row's own event time is ever joined. | |
| Feature groups (column prefixes, used for ablation): | |
| hist_ candidate vs the user's click log / own history / past impressions of that user-item pair | |
| it_ item popularity & CTR (full fit window, last 24h, last 6h, trend) | |
| co_ item-item co-click (user co-occurrence cosine) and sequential transitions vs user's last 5/20 clicks | |
| us_ user activity / CTR / recency / cold-start flags | |
| ev_ within-event rank / z-score / event max & mean of the main features | |
| tm_ time_bucket, day_type, hour, staleness since cutoff | |
| sv_ truncated SVD (tf-idf, row-normalised user x item click matrix as of C): user/history/item cosines | |
| mf_ logistic matrix factorisation score fitted on fit-window impressions | |
| """ | |
| import time | |
| import numpy as np | |
| import polars as pl | |
| from scipy import sparse | |
| from sklearn.utils.extmath import randomized_svd | |
| J20 = 1737331200 | |
| DAY = 86400 | |
| G_PRIOR = 0.0629 | |
| K_ITEM = 20 # pseudo-impressions for item CTR shrinkage | |
| K_USER = 5 # pseudo-events for user CTR shrinkage | |
| TOPK = 200 # co-click neighbours kept per item | |
| ID_COLS = ['rowid', 'eid', 'u', 'ii', 'ts', 'clicked', 'ord'] | |
| _t0 = time.time() | |
| def log(*a): | |
| print(time.strftime('%H:%M:%S'), *a, flush=True) | |
| def load(D): | |
| TE = pl.read_parquet(f'{D}/ev_train.parquet') | |
| TR = pl.read_parquet(f'{D}/rows_train.parquet') | |
| EE = pl.read_parquet(f'{D}/ev_eval.parquet') | |
| ER = pl.read_parquet(f'{D}/rows_eval.parquet') | |
| NI = pl.read_parquet(f'{D}/items.parquet').height | |
| NU = pl.read_parquet(f'{D}/users.parquet').height | |
| return TE, TR, EE, ER, NI, NU | |
| def _hist_long(E): | |
| return (E.select('u', 'h', 'h_ts').explode(['h', 'h_ts']).drop_nulls('h') | |
| .select('u', pl.col('h').cast(pl.Int32).alias('ii'), pl.col('h_ts').cast(pl.Int64).alias('ts'))) | |
| def click_log(C, P_ev, P_rows, T_ev): | |
| """Exact (user,item,ts) click log as of C: fit-window clicks + warm-up clicks seen in pre-C / own histories.""" | |
| win = P_rows.filter(pl.col('clicked') == 1).select('u', pl.col('ii').cast(pl.Int32), 'ts') | |
| hist = pl.concat([_hist_long(P_ev), _hist_long(T_ev)]).filter(pl.col('ts') < C) | |
| CL = pl.concat([win, hist]).unique().sort('u', 'ts', 'ii') | |
| return CL | |
| def cooc_tables(CL, NI): | |
| """User co-occurrence cosine (top-K per item) + lag-weighted transition probabilities prev->next.""" | |
| us = CL.select('u', 'ii').unique() | |
| pop = us.group_by('ii').agg(pl.len().alias('pop')) | |
| pairs = (us.join(us, on='u', suffix='_b').filter(pl.col('ii') != pl.col('ii_b')) | |
| .group_by('ii', 'ii_b').agg(pl.len().cast(pl.Int32).alias('co'))) | |
| co = (pairs.join(pop, on='ii').join(pop.rename({'ii': 'ii_b', 'pop': 'pop_b'}), on='ii_b') | |
| .with_columns((pl.col('co') / (pl.col('pop') * pl.col('pop_b')).sqrt()).cast(pl.Float32).alias('cos')) | |
| .select(pl.col('ii_b').alias('j'), 'ii', 'cos') # j = history item, ii = candidate | |
| .sort('cos', descending=True).group_by('j').head(TOPK)) | |
| seq = CL.select('u', 'ii') # already sorted by u, ts | |
| parts = [] | |
| for lag in (1, 2, 3): | |
| parts.append(seq.with_columns(pl.col('ii').shift(lag).over('u').alias('j')).drop_nulls('j') | |
| .select(pl.col('j').cast(pl.Int32), 'ii', pl.lit(1.0 / lag).alias('w'))) | |
| trn = pl.concat(parts).filter(pl.col('j') != pl.col('ii')).group_by('j', 'ii').agg(pl.col('w').sum()) | |
| tot = trn.group_by('j').agg(pl.col('w').sum().alias('tot')) | |
| trn = (trn.join(tot, on='j').with_columns((pl.col('w') / (pl.col('tot') + 5.0)).cast(pl.Float32).alias('trp')) | |
| .select('j', 'ii', 'trp').sort('trp', descending=True).group_by('j').head(TOPK)) | |
| return co, trn | |
| def fit_mf(P_rows, NU, NI, dim=24, epochs=6, lr=0.05, l2=1e-4, seed=0, batch=200_000): | |
| """Logistic MF logit = b + bu + bi + <pu, qi> fitted with Adagrad on fit-window impressions (numpy).""" | |
| rng = np.random.default_rng(seed) | |
| u = P_rows['u'].to_numpy().astype(np.int64); i = P_rows['ii'].to_numpy().astype(np.int64) | |
| y = P_rows['clicked'].to_numpy().astype(np.float32) | |
| P = (rng.standard_normal((NU, dim)) * 0.01).astype(np.float32); Q = (rng.standard_normal((NI, dim)) * 0.01).astype(np.float32) | |
| bu = np.zeros(NU, np.float32); bi = np.zeros(NI, np.float32) | |
| b = np.float32(np.log(y.mean() / (1 - y.mean()))) | |
| gP = np.full_like(P, 1e-6); gQ = np.full_like(Q, 1e-6); gbu = np.full_like(bu, 1e-6); gbi = np.full_like(bi, 1e-6) | |
| n = len(y) | |
| for ep in range(epochs): | |
| perm = rng.permutation(n) | |
| for s in range(0, n, batch): | |
| idx = perm[s:s + batch]; ub, ib, yb = u[idx], i[idx], y[idx] | |
| pu, qi = P[ub], Q[ib] | |
| z = b + bu[ub] + bi[ib] + (pu * qi).sum(1) | |
| g = (1 / (1 + np.exp(-z)) - yb).astype(np.float32) # dL/dz | |
| m = len(idx) | |
| Su = sparse.csr_matrix((np.ones(m, np.float32), (ub, np.arange(m))), shape=(NU, m)) | |
| Si = sparse.csr_matrix((np.ones(m, np.float32), (ib, np.arange(m))), shape=(NI, m)) | |
| dP = Su @ (g[:, None] * qi) + l2 * P * (np.asarray(Su.sum(1)).ravel() > 0)[:, None] | |
| dQ = Si @ (g[:, None] * pu) + l2 * Q * (np.asarray(Si.sum(1)).ravel() > 0)[:, None] | |
| dbu = Su @ g; dbi = Si @ g | |
| gP += dP * dP; gQ += dQ * dQ; gbu += dbu * dbu; gbi += dbi * dbi | |
| P -= lr * dP / np.sqrt(gP); Q -= lr * dQ / np.sqrt(gQ) | |
| bu -= lr * dbu / np.sqrt(gbu); bi -= lr * dbi / np.sqrt(gbi) | |
| return dict(b=b, bu=bu, bi=bi, P=P, Q=Q) | |
| def build(C, T_ev, T_rows, TE, TR, NI, NU, with_mf=True, sv_mode='std'): | |
| t0 = time.time() | |
| P_ev = TE.filter(pl.col('ts') < C) | |
| P_rows = TR.join(P_ev.select('eid', 'u', 'ts'), on='eid', how='inner') | |
| g = float(P_rows['clicked'].mean()) | |
| ndays = (C - J20) / DAY | |
| CL = click_log(C, P_ev, P_rows, T_ev) | |
| # ---------------- item ---------------- | |
| def ictr(c, n): | |
| return (pl.col(c) + K_ITEM * g) / (pl.col(n) + K_ITEM) | |
| it = P_rows.group_by('ii').agg( | |
| pl.len().alias('it_imp'), pl.col('clicked').sum().cast(pl.Int32).alias('it_clk'), | |
| (pl.col('ts') >= C - DAY).sum().alias('it_imp24'), pl.col('clicked').filter(pl.col('ts') >= C - DAY).sum().cast(pl.Int32).alias('it_clk24'), | |
| (pl.col('ts') >= C - 6 * 3600).sum().alias('it_imp6'), pl.col('clicked').filter(pl.col('ts') >= C - 6 * 3600).sum().cast(pl.Int32).alias('it_clk6')) | |
| it = it.with_columns(ictr('it_clk', 'it_imp').alias('it_ctr'), ictr('it_clk24', 'it_imp24').alias('it_ctr24'), | |
| ictr('it_clk6', 'it_imp6').alias('it_ctr6')) | |
| it = it.with_columns((pl.col('it_ctr24') / pl.col('it_ctr')).alias('it_trend24'), (pl.col('it_ctr6') / pl.col('it_ctr')).alias('it_trend6'), | |
| (pl.col('it_imp24') / (pl.col('it_imp') / ndays)).alias('it_imptrend24'), | |
| (pl.col('it_imp') / ndays).alias('it_imp_rate')) | |
| ip = CL.group_by('ii').agg(pl.len().alias('it_nclk_all'), pl.col('u').n_unique().alias('it_nusers'), | |
| (pl.col('ts') < J20).sum().alias('it_nclk_warm'), | |
| ((pl.col('ts') - C) / (2 * DAY)).exp().sum().cast(pl.Float32).alias('it_clk_dec2d')) | |
| # ---------------- user ---------------- | |
| ue = P_rows.group_by('u').agg(pl.col('eid').n_unique().alias('us_nev'), pl.col('clicked').sum().cast(pl.Int32).alias('us_nclk_win'), | |
| pl.col('ts').max().alias('_u_last_ev')) | |
| ue = ue.with_columns(((pl.col('us_nclk_win') + K_USER * 0.629) / (pl.col('us_nev') + K_USER)).alias('us_ctr')) | |
| uc = CL.group_by('u').agg(pl.len().alias('us_nclk_all'), (pl.col('ts') < J20).sum().alias('us_nclk_warm'), | |
| pl.col('ii').n_unique().alias('us_nuniq'), pl.col('ts').max().alias('_u_last_clk'), | |
| (pl.col('ts') >= C - DAY).sum().alias('us_nclk24')) | |
| uc = uc.with_columns((1 - pl.col('us_nuniq') / pl.col('us_nclk_all')).alias('us_repeat_rate')) | |
| # ---------------- user x item ---------------- | |
| uic = CL.group_by('u', 'ii').agg(pl.len().alias('hist_n_all'), (pl.col('ts') < J20).sum().alias('hist_n_warm'), | |
| (pl.col('ts') >= J20).sum().alias('hist_n_win'), pl.col('ts').max().alias('_ui_last_clk')) | |
| uii = P_rows.group_by('u', 'ii').agg(pl.len().alias('hist_imp'), pl.col('clicked').sum().cast(pl.Int32).alias('_ui_clk'), | |
| pl.col('ts').max().alias('_ui_last_imp')) | |
| uii = uii.with_columns((pl.col('hist_imp') - pl.col('_ui_clk')).alias('hist_noclk')) | |
| # ---------------- assemble ---------------- | |
| R = (T_rows.select([c for c in ('rowid', 'eid', 'ii', 'clicked', 'ord') if c in T_rows.columns]) | |
| .join(T_ev.select('eid', 'u', 'ts', 'tb', 'dt', 'h', 'h_ts'), on='eid', how='left').with_row_index('r')) | |
| R = (R.join(it, on='ii', how='left').join(ip, on='ii', how='left').join(ue, on='u', how='left').join(uc, on='u', how='left') | |
| .join(uic, on=['u', 'ii'], how='left').join(uii, on=['u', 'ii'], how='left')) | |
| R = R.with_columns( | |
| pl.col('h').list.len().alias('us_hlen'), | |
| (pl.col('h').list.len() == 0).cast(pl.Int8).alias('us_nohist'), | |
| pl.col('us_nev').is_null().cast(pl.Int8).alias('us_cold'), | |
| (pl.col('ts') - pl.col('_u_last_ev')).alias('us_since_ev'), | |
| (pl.col('ts') - pl.col('_u_last_clk')).alias('us_since_clk'), | |
| (pl.col('ts') - pl.col('_ui_last_clk')).alias('hist_since_clk'), | |
| (pl.col('ts') - pl.col('_ui_last_imp')).alias('hist_since_imp'), | |
| (pl.col('ts') - C).alias('tm_stale'), ((pl.col('ts') // 3600) % 24).cast(pl.Int8).alias('tm_hour'), | |
| pl.col('tb').alias('tm_tb'), pl.col('dt').alias('tm_dt')) | |
| # position (from end, 1 = most recent) of candidate in own history | |
| hx = (R.select('r', 'ii', 'h').with_columns(pl.int_ranges(pl.col('h').list.len(), 0, -1).alias('pos')) | |
| .explode(['h', 'pos']).drop_nulls('h').filter(pl.col('h') == pl.col('ii')) | |
| .group_by('r').agg(pl.col('pos').min().alias('hist_pos'), pl.len().alias('hist_n_own'))) | |
| R = R.join(hx, on='r', how='left') | |
| R = R.with_columns(pl.col('hist_since_clk').fill_null(30 * DAY), pl.col('hist_since_imp').fill_null(30 * DAY), | |
| pl.col('hist_pos').fill_null(99), pl.col('hist_n_own').fill_null(0), pl.col('us_since_ev').fill_null(30 * DAY), pl.col('us_since_clk').fill_null(30 * DAY)) | |
| R = R.with_columns((pl.col('hist_n_all').fill_null(0) > 0).cast(pl.Int8).alias('hist_in')) | |
| # ---------------- co-click ---------------- | |
| co, trn = cooc_tables(CL, NI) | |
| last20 = CL.group_by('u', maintain_order=True).agg(pl.col('ii').tail(20).alias('l20')) | |
| T_u = R.select('r', 'u', 'ii').join(last20, on='u', how='left') | |
| X = (T_u.with_columns(pl.int_ranges(pl.col('l20').list.len(), 0, -1).alias('pos')).explode(['l20', 'pos']) | |
| .drop_nulls('l20').rename({'l20': 'j'}).with_columns(pl.col('j').cast(pl.Int32)) | |
| .join(co, on=['j', 'ii'], how='left').join(trn, on=['j', 'ii'], how='left') | |
| .with_columns(pl.col('cos').fill_null(0), pl.col('trp').fill_null(0))) | |
| l5 = pl.col('pos') <= 5 | |
| agg = X.group_by('r').agg( | |
| pl.col('cos').max().alias('co_cos_max20'), pl.col('cos').mean().alias('co_cos_mean20'), | |
| pl.col('cos').filter(l5).max().alias('co_cos_max5'), pl.col('cos').filter(l5).mean().alias('co_cos_mean5'), | |
| pl.col('cos').filter(pl.col('pos') == 1).first().alias('co_cos_last'), | |
| pl.col('trp').max().alias('co_tr_max20'), pl.col('trp').mean().alias('co_tr_mean20'), | |
| pl.col('trp').filter(l5).max().alias('co_tr_max5'), pl.col('trp').filter(l5).mean().alias('co_tr_mean5'), | |
| pl.col('trp').filter(pl.col('pos') == 1).first().alias('co_tr_last'), | |
| (pl.col('cos') / pl.col('pos').cast(pl.Float32).sqrt()).sum().alias('co_cos_rw'), pl.col('cos').sum().alias('co_cos_sum20')) | |
| R = R.join(agg, on='r', how='left').sort('r') | |
| # ---------------- SVD embeddings ---------------- | |
| # sv_mode (leakage checks only): 'std' = click log as of C; 'notgt' = drop target events' own histories; | |
| # 'lag1d' = only clicks/impressions with ts < C - 1 day | |
| if sv_mode == 'std': | |
| CLsv, NEGsrc = CL, P_rows | |
| elif sv_mode == 'notgt': | |
| CLsv = pl.concat([P_rows.filter(pl.col('clicked') == 1).select('u', pl.col('ii').cast(pl.Int32), 'ts'), | |
| _hist_long(P_ev).filter(pl.col('ts') < C)]).unique() | |
| NEGsrc = P_rows | |
| elif sv_mode == 'lag1d': | |
| CLsv, NEGsrc = CL.filter(pl.col('ts') < C - DAY), P_rows.filter(pl.col('ts') < C - DAY) | |
| else: | |
| raise ValueError(sv_mode) | |
| assert (CLsv['ts'].max() or 0) < C and (NEGsrc['ts'].max() or 0) < C | |
| cnt = CLsv.group_by('u', 'ii').agg(pl.len().alias('n')) | |
| Xc = sparse.csr_matrix((np.ones(cnt.height, np.float32), (cnt['u'].to_numpy(), cnt['ii'].to_numpy())), shape=(NU, NI)) | |
| df_ = np.asarray(Xc.sum(0)).ravel(); idf = np.log((NU + 1) / (df_ + 1)).astype(np.float32) | |
| Xc = Xc @ sparse.diags(idf); rn = np.sqrt(np.asarray(Xc.multiply(Xc).sum(1)).ravel()) + 1e-6; Xc = sparse.diags(1 / rn) @ Xc | |
| uu = R['u'].to_numpy().astype(np.int64); iic = R['ii'].to_numpy().astype(np.int64) | |
| ex = X.select('r', 'j', 'pos').unique(['r', 'j', 'pos']) | |
| # per-event history (last 20 clicks) is shared by all candidates: take rows of one candidate per (r) anyway | |
| er_r = ex['r'].to_numpy(); er_j = ex['j'].to_numpy().astype(np.int64); er_w = (1.0 / np.sqrt(ex['pos'].to_numpy())).astype(np.float32) | |
| er_l5 = ex['pos'].to_numpy() <= 5 | |
| nR = R.height | |
| svcols = {} | |
| for k in (16, 32, 64): | |
| U, S, Vt = randomized_svd(Xc, k, random_state=0, n_iter=5) | |
| V = (Vt.T * np.sqrt(S)).astype(np.float32); Uf = (U * np.sqrt(S)).astype(np.float32) | |
| Vn = V / (np.linalg.norm(V, axis=1, keepdims=True) + 1e-9); Un = Uf / (np.linalg.norm(Uf, axis=1, keepdims=True) + 1e-9) | |
| cand = Vn[iic] | |
| svcols[f'sv_k{k}_ucos'] = (Un[uu] * cand).sum(1) | |
| svcols[f'sv_k{k}_udot'] = (Uf[uu] * V[iic]).sum(1) | |
| hv = np.zeros((nR, k), np.float32); np.add.at(hv, er_r, Vn[er_j] * er_w[:, None]) | |
| hn = np.linalg.norm(hv, axis=1); svcols[f'sv_k{k}_hcos'] = (hv * cand).sum(1) / (hn + 1e-9) | |
| if k == 32: | |
| svcols['sv_k32_hnorm'] = hn | |
| sims = (Vn[er_j] * Vn[iic[er_r]]).sum(1) | |
| mx = np.full(nR, -1.0, np.float32); np.maximum.at(mx, er_r, sims) | |
| mx5 = np.full(nR, -1.0, np.float32); np.maximum.at(mx5, er_r[er_l5], sims[er_l5]) | |
| svcols['sv_k32_imax20'] = mx; svcols['sv_k32_imax5'] = mx5 | |
| if k == 64: | |
| # click centroid vs non-clicked-impression centroid (impressions frozen at C: P_rows only) | |
| Sc = sparse.csr_matrix((np.ones(cnt.height, np.float32), (cnt['u'].to_numpy(), cnt['ii'].to_numpy())), shape=(NU, NI)) | |
| neg = NEGsrc.filter(pl.col('clicked') == 0) | |
| Sn = sparse.csr_matrix((np.ones(neg.height, np.float32), (neg['u'].to_numpy(), neg['ii'].to_numpy())), shape=(NU, NI)) | |
| nC = np.asarray(Sc @ Vn); nC /= np.linalg.norm(nC, axis=1, keepdims=True) + 1e-9 | |
| nN = np.asarray(Sn @ Vn); nN /= np.linalg.norm(nN, axis=1, keepdims=True) + 1e-9 | |
| cc = (nC[uu] * cand).sum(1); cn = (nN[uu] * cand).sum(1) | |
| svcols['sv_k64_ccos'] = cc; svcols['sv_k64_ncos'] = cn | |
| for a, nm in ((0.25, '025'), (0.5, '05'), (1.0, '1')): | |
| M = nC - a * nN; M /= np.linalg.norm(M, axis=1, keepdims=True) + 1e-9 | |
| svcols[f'sv_k64_con{nm}'] = (M[uu] * cand).sum(1) | |
| R = R.with_columns([pl.Series(c, v.astype(np.float32)) for c, v in svcols.items()]) | |
| # ---------------- MF ---------------- | |
| if with_mf: | |
| mf = fit_mf(P_rows, NU, NI) | |
| uu = R['u'].to_numpy().astype(np.int64); ii = R['ii'].to_numpy().astype(np.int64) | |
| dot = (mf['P'][uu] * mf['Q'][ii]).sum(1) | |
| R = R.with_columns(pl.Series('mf_score', mf['b'] + mf['bu'][uu] + mf['bi'][ii] + dot), | |
| pl.Series('mf_dot', dot), pl.Series('mf_bi', mf['bi'][ii])) | |
| fill0 = [c for c in R.columns if c.startswith(('it_imp', 'it_clk', 'it_n', 'us_n', 'hist_n', 'hist_imp', 'hist_noclk', 'co_')) and R[c].dtype != pl.List] | |
| R = R.with_columns([pl.col(c).fill_null(0) for c in fill0]) | |
| R = R.with_columns([pl.col(c).fill_null(g) for c in ('it_ctr', 'it_ctr24', 'it_ctr6')] + | |
| [pl.col(c).fill_null(1.0) for c in ('it_trend24', 'it_trend6', 'it_imptrend24')] + | |
| [pl.col('us_ctr').fill_null(g), pl.col('it_imp_rate').fill_null(0), pl.col('us_repeat_rate').fill_null(0)]) | |
| # ---------------- event context ---------------- | |
| main = ['it_ctr', 'it_ctr24', 'it_imp', 'it_nclk_all', 'it_clk_dec2d', 'hist_n_all', 'hist_noclk', | |
| 'co_cos_max20', 'co_cos_rw', 'co_tr_max5', 'co_tr_last', | |
| 'sv_k16_ucos', 'sv_k32_ucos', 'sv_k64_ucos', 'sv_k32_hcos', 'sv_k64_hcos', 'sv_k32_imax20', 'sv_k64_con05', 'sv_k64_con025', 'sv_k64_ccos'] + (['mf_score', 'mf_dot'] if with_mf else []) | |
| exprs = [] | |
| for c in main: | |
| x = pl.col(c).cast(pl.Float64) | |
| exprs += [x.rank('average').over('eid').alias(f'ev_{c}_rk'), | |
| ((x - x.mean().over('eid')) / (x.std().over('eid') + 1e-9)).alias(f'ev_{c}_z'), | |
| x.max().over('eid').alias(f'ev_{c}_max'), x.mean().over('eid').alias(f'ev_{c}_mean')] | |
| R = R.with_columns(exprs) | |
| R = R.drop([c for c in R.columns if c.startswith('_')] + ['h', 'h_ts', 'tb', 'dt']).sort('r').drop('r') | |
| log(f'build C={C} rows={R.height} cols={R.width} {time.time()-t0:.0f}s') | |
| return R | |
| GROUPS = ['it', 'hist', 'us', 'co', 'ev', 'tm', 'sv', 'mf'] | |
| def feature_cols(df, groups): | |
| cols = [] | |
| for c in df.columns: | |
| if c in ID_COLS: continue | |
| gname = c.split('_')[0] | |
| if gname == 'ev': | |
| src = c[3:].split('_')[0] # ev_<src group>_... | |
| if 'ev' in groups and src in groups: cols.append(c) | |
| elif gname in groups: | |
| cols.append(c) | |
| return cols | |