Files
xiaohongshu-cover-ctr-analysis/content_statistics.py
2026-11-05 01:04:53 +08:00

182 lines
8.0 KiB
Python

import itertools
import json
import math
from pathlib import Path
import numpy as np
import pandas as pd
from analyze import ols
ROOT = Path(__file__).parent
SEED = 20261017
rng = np.random.default_rng(SEED)
d = pd.read_json(ROOT / 'analysis_data.json')
categories = {
'技术作品/工具效果展示': [7, 9, 20, 24, 25, 27],
'Vibe Coding指南': [11, 15, 16],
'模型动态/观点': [5, 6, 8, 17, 18],
'安全/隐私事件': [14, 19, 21],
'其他': [10, 12, 13, 22, 23, 26, 28],
}
mapping = {row: group for group, rows in categories.items() for row in rows}
d['category'] = d.source_row.map(mapping)
d['october'] = d['首次发布时间'].str.contains('10月').astype(int)
assert d.category.notna().all() and len(d) == 24
def holm(ps):
ps = np.asarray(ps)
order = np.argsort(ps)
corrected = np.empty_like(ps)
running = 0.0
for i, index in enumerate(order):
running = max(running, min(1.0, (len(ps) - i) * ps[index]))
corrected[index] = running
return corrected.tolist()
def rank_test(a, b):
a, b = np.asarray(a), np.asarray(b)
na, nb = len(a), len(b)
rank2 = np.rint(pd.Series(np.r_[a, b]).rank().to_numpy() * 2).astype(int)
observed = int(rank2[:na].sum())
center = na * (na + nb + 1)
distance = abs(observed - center)
hits, total = 0, 0
# Every allocation of the observed ranks is equally likely under exchangeability.
for selection in itertools.combinations(rank2.tolist(), na):
hits += abs(sum(selection) - center) >= distance
total += 1
u = observed / 2 - na * (na + 1) / 2
superiority = u / (na * nb)
return {'n_a': na, 'n_b': nb, 'U': u, 'p_exact': hits / total,
'allocations': total, 'superiority_probability': superiority,
'cliff_delta': 2 * superiority - 1,
'median_a': float(np.median(a)), 'median_b': float(np.median(b))}
def boot_effect(a, b, draws=50000):
a, b = np.asarray(a), np.asarray(b)
aa = a[rng.integers(0, len(a), size=(draws, len(a)))]
bb = b[rng.integers(0, len(b), size=(draws, len(b)))]
med_ratio = np.median(aa, axis=1) / np.median(bb, axis=1)
delta = (aa[:, :, None] > bb[:, None, :]).mean(axis=(1, 2)) - (aa[:, :, None] < bb[:, None, :]).mean(axis=(1, 2))
return {'median_ratio': float(np.median(a) / np.median(b)),
'median_ratio_ci95_percentile': np.quantile(med_ratio, [.025, .975]).tolist(),
'cliff_delta_ci95_percentile': np.quantile(delta, [.025, .975]).tolist(),
'bootstrap_draws': draws}
def kw_permutation(sub, outcome, draws=200000):
groups = [g for _, g in sub.groupby('category', sort=False)]
sizes = np.array([len(g) for g in groups])
vals = np.concatenate([g[outcome].to_numpy() for g in groups])
ranks = pd.Series(vals).rank().to_numpy()
n = len(ranks)
_, counts = np.unique(vals, return_counts=True)
correction = 1 - np.sum(counts**3 - counts) / (n**3 - n)
starts = np.r_[0, np.cumsum(sizes)[:-1]]
def statistic(r):
sums = np.add.reduceat(r, starts, axis=-1)
return (12 / (n * (n + 1)) * np.sum(sums**2 / sizes, axis=-1) - 3 * (n + 1)) / correction
h = float(statistic(ranks))
hits = 0
for begin in range(0, draws, 5000):
batch = min(5000, draws - begin)
shuffled = ranks[np.argsort(rng.random((batch, n)), axis=1)]
hits += int(np.sum(statistic(shuffled) >= h - 1e-10))
p = (hits + 1) / (draws + 1)
return {'n': n, 'H': h, 'p_permutation': p, 'draws': draws,
'monte_carlo_se': math.sqrt(p * (1 - p) / (draws + 1)),
'epsilon_squared': (h - len(groups) + 1) / (n - len(groups))}
def loo_r2(y, x):
y, x = np.asarray(y), np.asarray(x)
X = np.column_stack([np.ones(len(y)), x])
beta = np.linalg.lstsq(X, y, rcond=None)[0]
e = y - X @ beta
h = np.sum((X @ np.linalg.inv(X.T @ X)) * X, axis=1)
press = np.sum((e / (1 - h))**2)
return float(1 - press / np.sum((y - y.mean())**2))
result = {'seed': SEED, 'n': len(d), 'genre': {}, 'topic_omnibus': {},
'topic_pairwise_views': [], 'regressions': {}, 'topic_summary': []}
genre_tests = []
for outcome in ['观看量', '曝光', '封面点击率']:
test = rank_test(d[d.video == 1][outcome], d[d.video == 0][outcome])
result['genre'][outcome] = test
genre_tests.append(test['p_exact'])
for outcome, adjusted in zip(result['genre'], holm(genre_tests)):
result['genre'][outcome]['p_holm_3'] = adjusted
result['genre']['观看量'].update(boot_effect(d[d.video == 1]['观看量'], d[d.video == 0]['观看量']))
for subset_name, sub in [('all', d), ('without_top', d[d.source_row != 9]), ('september', d[d.october == 0])]:
if subset_name != 'all':
result['genre'][subset_name + '_views'] = rank_test(sub[sub.video == 1]['观看量'], sub[sub.video == 0]['观看量'])
for outcome in ['观看量', '曝光']:
covariates = ['video', 'october'] if subset_name != 'september' else ['video']
fit = ols(np.log(sub[outcome]), sub[covariates], covariates)
fit['loo_r2_log_target'] = loo_r2(np.log(sub[outcome]), sub[covariates])
result['regressions'][f'{subset_name}_{outcome}_format_cohort'] = fit
for outcome in ['观看量', '曝光', '封面点击率']:
result['topic_omnibus'][outcome] = kw_permutation(d, outcome)
for outcome, adjusted in zip(result['topic_omnibus'], holm([x['p_permutation'] for x in result['topic_omnibus'].values()])):
result['topic_omnibus'][outcome]['p_holm_3'] = adjusted
result['topic_omnibus']['without_top_views'] = kw_permutation(d[d.source_row != 9], '观看量')
for (ka, ra), (kb, rb) in itertools.combinations(categories.items(), 2):
aa, bb = d[d.source_row.isin(ra)]['观看量'], d[d.source_row.isin(rb)]['观看量']
test = rank_test(aa, bb)
test.update({'a': ka, 'b': kb})
result['topic_pairwise_views'].append(test)
for test, adjusted in zip(result['topic_pairwise_views'], holm([x['p_exact'] for x in result['topic_pairwise_views']])):
test['p_holm_10'] = adjusted
trim_pairs = []
for (ka, ra), (kb, rb) in itertools.combinations(categories.items(), 2):
aa = d[d.source_row.isin(ra) & (d.source_row != 9)]['观看量']
bb = d[d.source_row.isin(rb) & (d.source_row != 9)]['观看量']
test = rank_test(aa, bb)
test.update({'a': ka, 'b': kb})
trim_pairs.append(test)
for test, adjusted in zip(trim_pairs, holm([x['p_exact'] for x in trim_pairs])):
test['p_holm_10'] = adjusted
result['topic_pairwise_views_without_top'] = trim_pairs
result['within_video_demo_vs_other'] = rank_test(
d[(d.video == 1) & (d.category == '技术作品/工具效果展示')]['观看量'],
d[(d.video == 1) & (d.category != '技术作品/工具效果展示')]['观看量'])
for name, rows in categories.items():
sub = d[d.source_row.isin(rows)]
result['topic_summary'].append({'group': name, 'n': len(sub), 'video_n': int(sub.video.sum()),
'median_views': float(sub['观看量'].median()), 'median_impressions': float(sub['曝光'].median()),
'median_ctr': float(sub['封面点击率'].median())})
# Conditional exact permutation within month cohorts, using the adjusted regression coefficient.
y = np.log(d['观看量'].to_numpy())
video = d.video.to_numpy()
month = d.october.to_numpy()
indices = [np.flatnonzero(month == value) for value in [0, 1]]
counts = [int(video[ix].sum()) for ix in indices]
centered_y = y.copy()
for ix in indices:
centered_y[ix] -= y[ix].mean()
observed = float(centered_y @ video)
hits, total = 0, 0
for a in itertools.combinations(indices[0].tolist(), counts[0]):
sa = centered_y[list(a)].sum()
for b in itertools.combinations(indices[1].tolist(), counts[1]):
value = sa + centered_y[list(b)].sum()
hits += abs(value) >= abs(observed) - 1e-12
total += 1
result['genre']['views_month_stratified_permutation'] = {'p_exact': hits / total, 'allocations': total}
(ROOT / 'content_statistics_results.json').write_text(json.dumps(result, ensure_ascii=False, indent=2))
print('CONTENT_STATISTICS', json.dumps(result, ensure_ascii=False, indent=2))