Validation in ML

Cross Validation

Stratified Group K-Fold#

Original Code: jakubwasikowski’s code

Original code with explanation#

# %%timeit
# 7.23 s ± 141 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

labels_num = np.max(y) + 1  # label이 0~9로 구성이 되어 있어 최대 값에 1을 더해 label 개수를 파악합니다.
# 각 image 별로 label 개수를 확인하기 위해 지정합니다.
y_counts_per_group = defaultdict(lambda: np.zeros(labels_num))  # dict shape: (n_image, n_label)
# 각 label 별 개수를 확인하기 위해 지정합니다.
y_distr = Counter()
# annotation 데이터를 이용해 순차적으로 데이터를 확인합니다.
for label, g in zip(y, groups):
    y_counts_per_group[g][label] += 1
    y_distr[label] += 1

y_counts_per_fold = defaultdict(lambda: np.zeros(labels_num))  # fold별 각 label 개수를 파악하기 위해 지정합니다.

# 어떤 image가 특정 fold에 속할 때 전체 fold의 std 값 확인하는 함수입니다.
def eval_y_counts_per_fold(y_counts, fold):
    y_counts_per_fold[fold] += y_counts  # image가 특정 fold에 속한 것을 반영합니다.
    std_per_label = []
    # label 별로 전체 fold에 대해 std 값을 확인합니다.
    for label in range(labels_num):
        # label 별로 개수가 다르기 때문에 scale을 맞추기 위해 각 label 별 총 개수로 나누어 줍니다.
        label_std = np.std([y_counts_per_fold[i][label] / y_distr[label] for i in range(k)])
        std_per_label.append(label_std)
    # 본 함수는 해당 image가 특정 fold에 속한 것을 가정하고 계산합니다. 해당 image가 특정 fold에 속하지 않을 수 있기 때문에 원상 복구 합니다.
    y_counts_per_fold[fold] -= y_counts
    # label별 std를 평균냅니다.
    return np.mean(std_per_label)

# image당 각 label 개수에 대한 값들만 활용합니다.
groups_and_y_counts = list(y_counts_per_group.items())
# shuffle 후 sort 하는 것이 이상해 보이지만, sort 하였을 때 같은 std 값일 경우 순서가 달라지게 됩니다.
random.Random(seed).shuffle(groups_and_y_counts)
groups_per_fold = defaultdict(set)  # 각 fold별 index를 저장하기 위해 지정합니다.

# 큰 std를 가진 image부터 순차적으로 best fold를 찾습니다.
for g, y_counts in sorted(groups_and_y_counts, key=lambda x: -np.std(x[1])):
    best_fold = None
    min_eval = None
    # image가 어떤 fold에 속할 때 가장 낮은 std를 가지는지 확인하는 과정입니다.
    for i in range(k):  # 각 fold에 대해 순차적으로 확인합니다.
        fold_eval = eval_y_counts_per_fold(y_counts, i)  # 전체 fold에 대해 std값 확인.
        # std 값이 가장 낮을 때가 best fold 입니다.
        if min_eval is None or fold_eval < min_eval:
            min_eval = fold_eval
            best_fold = i
    y_counts_per_fold[best_fold] += y_counts  # best fold를 확정하고 해당 image를 반영합니다.
    groups_per_fold[best_fold].add(g)  # best fold에 해당 이미지의 index를 추가합니다.

Summary with metaphor#

다양한 크기의 감자(image)를 수확했습니다. (갑자기?) 우리는 이것을 5개 상자(fold)에 나누어 담아야 합니다.

하지만! 5개의 상자에 다양한 크기의 감자들이 골고루 담겨야 하며, 5개 상자 무게 모두 동일해야 합니다. (예?!)

다양한 크기의 감자를 상자에 넣기 위해서는 큰 감자는 가장 먼저 넣는게 좋을 것 같네요.

큰 감자는 나중에 넣게 되면 쉽게 넘칠 수 있고 무게 또한 많이 달라지기 때문에 가장 먼저 넣는게 좋을 것 같습니다. 정리를 할 때도 큰 것을 먼저 배치하면 좋다고 하니까요..

큰 감자부터 순서대로 차곡차곡 상자에 담습니다. 중간 중간 무게도 확인하면서요.

그 결과 상자별 감자 개수는 다를지 몰라도 다양한 크기를 가진 감자들이 균일하게 담긴 것 같네요.

https://s3-us-west-2.amazonaws.com/aistages-prod-server-public/app/Users/00000526/files/54c3f53b-8534-49f9-a594-d6aeffae568f..png

그렇군요. 감자하게도 잘 된 것 같네요. :ㅇ

(정확한 설명은 아니지만 이런 뉘앙스를 가진다 이해해주시면 감사하겠습니다.)

마지막으로 좀 더 선별을 잘 하기 위해 최신 저울(pandas, numpy)을 도입하기로 했습니다.

Optimized code with numpy and pandas#

# %%timeit
# 838 ms ± 28.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

labels_num = y.max() + 1
# https://stackoverflow.com/a/39132900/14019325
# 기존 코드의 첫번째 loop와 동일합니다. 각 image 별 label 개수를 확인합니다.
y_counts_per_group = df.groupby(['image_id', 'category_id']).size().unstack(fill_value=0)
y_counts_per_fold = np.zeros((k, labels_num))

# scale을 미리 계산하여 연산을 줄입니다.
y_norm_counts_per_group = y_counts_per_group / y_counts_per_group.sum()
# suffle & sort
shuffled_and_sorted_index = y_norm_counts_per_group.sample(frac=1, random_state=seed).std(axis=1).sort_values(ascending=False).index
y_norm_counts_per_group = y_norm_counts_per_group.loc[shuffled_and_sorted_index]

groups_per_fold = defaultdict(set)

for g, y_counts in zip(y_norm_counts_per_group.index, y_norm_counts_per_group.values):
    best_fold = None
    min_eval = None
    for fold_i in range(k):
        # 기존 코드 eval_y_counts_per_fold 와 동일합니다.
        y_counts_per_fold[fold_i] += y_counts
        fold_eval = y_counts_per_fold.std(axis=0).mean()  # numpy를 활용하여 연산을 단순화 합니다.
        # `.std(axis=0)` 각 label 별 std 계산 후 평균
        y_counts_per_fold[fold_i] -= y_counts
        if min_eval is None or fold_eval < min_eval:
            min_eval = fold_eval
            best_fold = fold_i
    y_counts_per_fold[best_fold] += y_counts
    groups_per_fold[best_fold].add(g)

최신 저울을 추가했더니 속도가 확실히 빨라졌네요. 7.23s –> 0.84s 로 개선이 됐습니다.

pandas와 numpy를 활용해 loop 수를 최소화 하여 간략화 한것이 도움이 됐습니다.

Check distributions of splited data#

def get_distribution(y_vals):
        y_distr = Counter(y_vals)
        y_vals_sum = sum(y_distr.values())
        return [f'{y_distr[i] / y_vals_sum:.2%}' for i in range(np.max(y_vals) + 1)]

all_groups = set(groups)

distrs = [get_distribution(y)]
index = ['training set']

for i in range(k):
    train_groups = all_groups - groups_per_fold[i]
    test_groups = groups_per_fold[i]

    train_df = df.loc[df['image_id'].isin(train_groups)]
    valid_df = df.loc[df['image_id'].isin(test_groups)]

    distrs.append(get_distribution(train_df['category_id'].values))
    index.append(f'train set - fold {i + 1}')
    distrs.append(get_distribution(valid_df['category_id'].values))
    index.append(f'validation set - fold {i + 1}')

print('Distribution per class:')
pd.DataFrame(distrs, index=index, columns=[f'Label {l}' for l in range(np.max(y) + 1)])

Optimized code with numpy and polars#

Not yet implemented.