kyu823's picture
Use HF dataset logs and improve survey outputs
f84a7bc verified
Raw
History Blame Contribute Delete
23.3 kB
import { AGE_BANDS, GENDERS, LOCATION_OPTIONS, PERSONA_OPTIONS, REGIONS } from "./data";
import type {
AgeBandId,
CategoricalAnswer,
GenderId,
LikertAnswer,
LocationId,
OpenAnswer,
PersonaAttributeId,
PersonaDimensionId,
QuestionStat,
RegionId,
RegionStat,
SiliconConfig,
SiliconResult,
SurveyQuestion,
SyntheticRespondent,
WeightedPick,
} from "./types";
const REGION_TENDENCY: Record<RegionId, number> = {
seoul: 0.03,
busan: -0.02,
daegu: -0.08,
incheon: 0.01,
gwangju: 0.1,
daejeon: 0.02,
ulsan: -0.03,
sejong: 0.04,
gyeonggi: 0.02,
gangwon: -0.02,
chungbuk: -0.01,
chungnam: -0.02,
jeonbuk: 0.06,
jeonnam: 0.08,
gyeongbuk: -0.07,
gyeongnam: -0.04,
jeju: 0.05,
};
const AGE_TENDENCY: Record<AgeBandId, number> = {
"20s": 0.03,
"30s": 0.01,
"40s": 0,
"50s": -0.01,
"60plus": -0.02,
};
const THEMES = ["물가", "주거", "일자리", "돌봄", "교통", "의료", "지역경제", "교육", "기후", "정치 신뢰"];
const PERSONA_DIMENSIONS: PersonaDimensionId[] = ["occupation", "education", "housing", "marital", "family"];
export function defaultPicks<T extends string>(items: Array<{ id: T; defaultWeight: number }>): WeightedPick<T>[] {
return items.map((item) => ({ id: item.id, enabled: true, weight: item.defaultWeight }));
}
export function simulateSiliconSampling(config: SiliconConfig): SiliconResult {
const rand = mulberry32(config.seed);
const respondents: SyntheticRespondent[] = [];
const likertAnswers: LikertAnswer[] = [];
const categoricalAnswers: CategoricalAnswer[] = [];
const openAnswers: OpenAnswer[] = [];
const likertQuestions = config.questions.filter((question) => question.kind === "likert");
const categoricalQuestions = config.questions.filter((question) => question.kind === "categorical");
const openQuestions = config.questions.filter((question) => question.kind === "open");
const genderPool = buildAllocationPool(config.genders, config.sampleSize, "female", rand);
const agePool = buildAllocationPool(config.ages, config.sampleSize, "40s", rand);
const locationPool = buildAllocationPool(config.locations, config.sampleSize, "seoul", rand);
const personaPools = buildPersonaPools(config.personaAttributes, config.sampleSize, rand);
for (let index = 0; index < config.sampleSize; index += 1) {
const gender = genderPool[index] || "female";
const age = agePool[index] || "40s";
const location = locationPool[index] || "seoul";
const locationOption = locationOptionOf(location, config.locationOptions);
const persona = personaAttributesFromPools(personaPools, index);
const respondent = buildRespondent(index, gender, age, locationOption.id, locationOption.parentRegion, locationOption.label, persona, rand);
respondents.push(respondent);
for (const question of likertQuestions) {
likertAnswers.push({
respondentId: respondent.id,
questionId: question.id,
value: answerLikert(question, respondent, rand),
rationale: answerLikertRationale(question, respondent),
});
}
for (const question of categoricalQuestions) {
categoricalAnswers.push({
respondentId: respondent.id,
questionId: question.id,
...answerCategorical(question, respondent, rand),
});
}
for (const question of openQuestions) {
openAnswers.push({
respondentId: respondent.id,
questionId: question.id,
...answerOpen(question, respondent, rand),
});
}
}
const primaryQuestionId = likertQuestions[0]?.id ?? null;
return {
config,
respondents,
likertAnswers,
categoricalAnswers,
openAnswers,
regionStats: buildRegionStats(config.locations, config.locationOptions, respondents, likertAnswers, openAnswers, likertQuestions[0]),
questionStats: buildQuestionStats(config.questions, likertAnswers, categoricalAnswers),
primaryQuestionId,
};
}
function buildRespondent(
index: number,
gender: GenderId,
age: AgeBandId,
location: LocationId,
region: RegionId,
locationLabel: string,
persona: ReturnType<typeof pickPersonaAttributes>,
rand: () => number,
): SyntheticRespondent {
const regionBias = REGION_TENDENCY[region] || 0;
const ageBias = AGE_TENDENCY[age] || 0;
const genderBias = gender === "female" ? 0.015 : gender === "male" ? -0.01 : 0;
const personaBias = personaBiases(persona.attributes);
const economicAnxiety = clamp01(0.5 + (age === "30s" || age === "40s" ? 0.08 : 0) + (region === "seoul" || region === "gyeonggi" ? 0.04 : 0) + personaBias.anxiety + noise(rand, 0.18));
const trust = clamp01(0.48 + regionBias + ageBias + genderBias + personaBias.trust - economicAnxiety * 0.08 + noise(rand, 0.16));
const participation = clamp01(0.5 + (age === "60plus" ? 0.12 : 0) + (age === "20s" ? -0.06 : 0) + Math.abs(regionBias) * 0.4 + personaBias.participation + noise(rand, 0.14));
return {
id: `R${String(index + 1).padStart(4, "0")}`,
gender,
age,
region,
location,
locationLabel,
personaAttributes: persona.attributes,
personaLabels: persona.labels,
segment: segmentLabel(trust, economicAnxiety, participation),
trust,
economicAnxiety,
participation,
};
}
function answerLikert(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number) {
const scale = question.scale || 5;
let score = scale / 2 + 0.5;
const trustTerm = (respondent.trust - 0.5) * scale * 0.8;
const anxietyTerm = (respondent.economicAnxiety - 0.5) * scale * 0.58;
const participationTerm = (respondent.participation - 0.5) * scale * 0.32;
const regionTerm = (REGION_TENDENCY[respondent.region] || 0) * scale;
if (question.id.includes("approval") || question.id.includes("trust")) score += trustTerm + regionTerm;
else if (question.id.includes("economic") || question.id.includes("household")) score += -anxietyTerm + trustTerm * 0.28;
else if (question.id.includes("climate")) score += participationTerm + (respondent.age === "20s" ? 0.35 : 0) - (respondent.age === "60plus" ? 0.18 : 0);
else if (question.id.includes("birth")) score += -anxietyTerm * 0.35 + (respondent.age === "30s" ? -0.18 : 0.08);
else if (question.id.includes("news")) score += trustTerm * 0.35 - (respondent.age === "20s" ? 0.1 : 0) + (respondent.age === "60plus" ? 0.15 : 0);
else score += trustTerm * 0.35 - anxietyTerm * 0.18;
score += noise(rand, scale * 0.36);
return Math.max(1, Math.min(scale, Math.round(score)));
}
function answerLikertRationale(question: SurveyQuestion, respondent: SyntheticRespondent) {
const age = labelOf(AGE_BANDS, respondent.age);
const gender = labelOf(GENDERS, respondent.gender);
const region = labelOf(REGIONS, respondent.region);
const persona = respondent.personaLabels.occupation || respondent.segment;
if (question.id.includes("economic") || question.id.includes("household")) {
return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 생활비와 소득 안정성을 함께 고려했습니다.`;
}
if (question.id.includes("trust") || question.id.includes("approval")) {
return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 제도 신뢰와 최근 정책 체감도를 기준으로 판단했습니다.`;
}
if (question.id.includes("climate")) {
return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 환경 필요성과 비용 부담을 함께 보았습니다.`;
}
return `${region} 거주 ${age} ${gender} ${persona} 응답자로서 현재 생활 여건과 관심사를 반영했습니다.`;
}
function answerOpen(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number): { text: string; theme: string; rationale: string } {
const region = labelOf(REGIONS, respondent.region);
const age = labelOf(AGE_BANDS, respondent.age);
const occupation = respondent.personaLabels.occupation ? ` ${respondent.personaLabels.occupation}` : "";
let theme = THEMES[Math.floor(rand() * THEMES.length)];
if (respondent.economicAnxiety > 0.68) theme = rand() > 0.5 ? "물가" : "주거";
if (respondent.age === "20s" || respondent.age === "30s") theme = rand() > 0.45 ? "일자리" : "주거";
if (respondent.age === "60plus") theme = rand() > 0.5 ? "의료" : "돌봄";
if (question.id.includes("local")) theme = rand() > 0.5 ? "교통" : "지역경제";
if (question.id.includes("policy")) theme = rand() > 0.5 ? "주거" : "정치 신뢰";
const tone = respondent.trust > 0.58 ? "지금보다 체감 가능한 방식으로 확대되면 좋겠습니다" : "구호보다 실제 집행과 설명이 먼저 필요합니다";
const text = `${region} 거주 ${age}${occupation} 응답자로서 ${theme} 문제가 가장 크게 느껴집니다. ${tone}.`;
const rationale = `${region}, ${age}, ${occupation.trim() || respondent.segment} 특성에서 가장 직접적으로 체감되는 이슈를 우선했습니다.`;
return { theme, text, rationale };
}
function answerCategorical(question: SurveyQuestion, respondent: SyntheticRespondent, rand: () => number): { optionId: string; label: string; rationale: string } {
const options = question.options?.length ? question.options : [{ id: "opt_1", label: "기타" }];
const ageTilt = respondent.age === "20s" || respondent.age === "30s" ? 0 : respondent.age === "60plus" ? 2 : 1;
const anxietyTilt = respondent.economicAnxiety > 0.6 ? 0 : 1;
const index = Math.min(options.length - 1, Math.max(0, Math.floor((rand() * options.length + ageTilt + anxietyTilt) / 3)));
const option = options[index] || options[0];
const rationale = `${labelOf(REGIONS, respondent.region)} 거주 ${labelOf(AGE_BANDS, respondent.age)} 응답자로서 현재 생활 여건과 persona 특성에 가장 가까운 선택지를 골랐습니다.`;
return { optionId: option.id, label: option.label, rationale };
}
function buildRegionStats(
locations: WeightedPick<LocationId>[],
locationOptions: SiliconConfig["locationOptions"],
respondents: SyntheticRespondent[],
likertAnswers: LikertAnswer[],
openAnswers: OpenAnswer[],
primaryQuestion?: SurveyQuestion,
): RegionStat[] {
const primaryQuestionId = primaryQuestion?.id ?? null;
const scale = primaryQuestion?.scale || 5;
const positiveCut = Math.max(3, Math.ceil(scale * 0.7));
const enabledLocations = locations.filter((location) => location.enabled && location.weight > 0);
return enabledLocations.map((locationPick) => {
const option = locationOptionOf(locationPick.id, locationOptions);
const people = respondents.filter((respondent) => respondent.location === option.id);
const ids = new Set(people.map((respondent) => respondent.id));
const answers = primaryQuestionId ? likertAnswers.filter((answer) => answer.questionId === primaryQuestionId && ids.has(answer.respondentId)) : [];
return {
region: option.id,
parentRegion: option.parentRegion,
label: option.label,
respondents: people.length,
mean: mean(answers.map((answer) => answer.value)),
scale,
positiveShare: answers.length ? answers.filter((answer) => answer.value >= positiveCut).length / answers.length : 0,
openCount: openAnswers.filter((answer) => ids.has(answer.respondentId)).length,
};
});
}
function buildQuestionStats(questions: SurveyQuestion[], likertAnswers: LikertAnswer[], categoricalAnswers: CategoricalAnswer[]): QuestionStat[] {
return questions.map((question) => {
if (question.kind === "open") return { questionId: question.id, title: question.title, kind: "open" };
if (question.kind === "categorical") {
const answers = categoricalAnswers.filter((answer) => answer.questionId === question.id);
const options = question.options || [];
const distribution = options.map((option) => {
const count = answers.filter((answer) => answer.optionId === option.id).length;
return { optionId: option.id, label: option.label, count, share: answers.length ? count / answers.length : 0 };
});
return {
questionId: question.id,
title: question.title,
kind: "categorical",
distribution,
};
}
const scale = question.scale || 5;
const answers = likertAnswers.filter((answer) => answer.questionId === question.id);
const distribution = Array.from({ length: scale }, (_, index) => {
const value = index + 1;
const count = answers.filter((answer) => answer.value === value).length;
return { value, count, share: answers.length ? count / answers.length : 0 };
});
const positiveCut = Math.max(3, Math.ceil(scale * 0.7));
return {
questionId: question.id,
title: question.title,
kind: "likert",
scale,
mean: mean(answers.map((answer) => answer.value)),
positiveShare: answers.length ? answers.filter((answer) => answer.value >= positiveCut).length / answers.length : 0,
distribution,
};
});
}
export function groupBreakdown(result: SiliconResult, dimension: "gender" | "age") {
const primary = result.primaryQuestionId;
if (!primary) return [];
const answerByRespondent = new Map(result.likertAnswers.filter((answer) => answer.questionId === primary).map((answer) => [answer.respondentId, answer.value]));
const options = dimension === "gender" ? GENDERS : AGE_BANDS;
return options.map((option) => {
const people = result.respondents.filter((respondent) => respondent[dimension] === option.id);
const values = people.map((respondent) => answerByRespondent.get(respondent.id)).filter((value): value is number => typeof value === "number");
return {
id: option.id,
label: option.label,
respondents: people.length,
mean: mean(values),
};
});
}
export function resultBreakdown(result: SiliconResult, questionId: string, dimension: "gender" | "age" | "region" | PersonaDimensionId) {
const question = result.config.questions.find((item) => item.id === questionId);
if (!question || (question.kind !== "likert" && question.kind !== "categorical")) return [];
const valuesByRespondent = new Map<string, number | string>();
if (question.kind === "likert") {
for (const answer of result.likertAnswers.filter((item) => item.questionId === questionId)) {
valuesByRespondent.set(answer.respondentId, answer.value);
}
} else {
for (const answer of result.categoricalAnswers.filter((item) => item.questionId === questionId)) {
valuesByRespondent.set(answer.respondentId, answer.optionId);
}
}
const scale = question.scale || 5;
const positiveCut = Math.max(3, Math.ceil(scale * 0.7));
const options = dimension === "gender"
? GENDERS.map((option) => ({ id: option.id, label: option.label }))
: dimension === "age"
? AGE_BANDS.map((option) => ({ id: option.id, label: option.label }))
: dimension === "region"
? result.regionStats.map((option) => ({ id: option.region, label: option.label }))
: personaBreakdownOptions(result, dimension);
return options.map((option) => {
const id = option.id;
const label = option.label;
const people = result.respondents.filter((respondent) => {
if (dimension === "gender") return respondent.gender === id;
if (dimension === "age") return respondent.age === id;
if (dimension === "region") return respondent.location === id;
return respondent.personaAttributes[dimension] === id;
});
const values = people.map((respondent) => valuesByRespondent.get(respondent.id)).filter((value): value is number | string => value !== undefined);
const numericValues = values.filter((value): value is number => typeof value === "number");
const distribution = question.kind === "likert"
? Array.from({ length: scale }, (_, index) => {
const value = index + 1;
const count = values.filter((item) => item === value).length;
return { id: String(value), label: `${value}점`, count, share: values.length ? count / values.length : 0 };
})
: (question.options || []).map((questionOption) => {
const count = values.filter((item) => item === questionOption.id).length;
return { id: questionOption.id, label: questionOption.label, count, share: values.length ? count / values.length : 0 };
});
return {
id,
label,
respondents: people.length,
mean: mean(numericValues),
positiveShare: numericValues.length ? numericValues.filter((value) => value >= positiveCut).length / numericValues.length : 0,
distribution,
};
}).filter((item) => item.respondents > 0);
}
function personaBreakdownOptions(result: SiliconResult, dimension: PersonaDimensionId) {
const rows = new Map<string, { id: string; label: string }>();
for (const respondent of result.respondents) {
const id = respondent.personaAttributes[dimension];
if (!id) continue;
rows.set(id, { id, label: respondent.personaLabels[dimension] || id });
}
return [...rows.values()];
}
export function selectedWeightTotal<T extends string>(picks: WeightedPick<T>[]) {
return picks.filter((pick) => pick.enabled).reduce((total, pick) => total + Math.max(0, pick.weight), 0);
}
function pickWeighted<T extends string>(items: WeightedPick<T>[], rand: () => number, fallback: T): T {
const enabled = items.filter((item) => item.enabled && item.weight > 0);
const total = selectedWeightTotal(enabled);
if (!enabled.length || total <= 0) return fallback;
let cursor = rand() * total;
for (const item of enabled) {
cursor -= item.weight;
if (cursor <= 0) return item.id;
}
return enabled[enabled.length - 1].id;
}
function buildAllocationPool<T extends string>(items: WeightedPick<T>[], targetSize: number, fallback: T, rand: () => number): T[] {
const enabled = items.filter((item) => item.enabled && item.weight > 0);
if (!enabled.length) return Array.from({ length: targetSize }, () => fallback);
const allocations = allocatePickCounts(enabled, targetSize);
const pool: T[] = [];
for (const item of enabled) {
const count = allocations.get(item.id) || 0;
for (let index = 0; index < count; index += 1) pool.push(item.id);
}
while (pool.length < targetSize) pool.push(fallback);
shuffle(pool, rand);
return pool.slice(0, targetSize);
}
function allocatePickCounts<T extends string>(items: WeightedPick<T>[], targetSize: number) {
const total = items.reduce((sum, item) => sum + Math.max(0, item.weight), 0);
if (total <= 0) return new Map<T, number>();
const rows = items.map((item) => {
const raw = Math.max(0, item.weight) / total * targetSize;
return { id: item.id, floor: Math.floor(raw), remainder: raw - Math.floor(raw) };
});
let used = rows.reduce((sum, row) => sum + row.floor, 0);
for (const row of rows.sort((a, b) => b.remainder - a.remainder)) {
if (used >= targetSize) break;
row.floor += 1;
used += 1;
}
return new Map(rows.map((row) => [row.id, row.floor]));
}
function shuffle<T>(items: T[], rand: () => number) {
for (let index = items.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(rand() * (index + 1));
[items[index], items[swapIndex]] = [items[swapIndex], items[index]];
}
}
function locationOptionOf(id: LocationId, options: SiliconConfig["locationOptions"] = LOCATION_OPTIONS) {
return options.find((location) => location.id === id) || LOCATION_OPTIONS.find((location) => location.id === "seoul")!;
}
function pickPersonaAttributes(items: WeightedPick<PersonaAttributeId>[], rand: () => number) {
const attributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>> = {};
const labels: Partial<Record<PersonaDimensionId, string>> = {};
for (const dimension of PERSONA_DIMENSIONS) {
const options = PERSONA_OPTIONS.filter((option) => option.dimension === dimension);
const picks = items.filter((item) => options.some((option) => option.id === item.id));
const picked = pickWeighted(picks, rand, "" as PersonaAttributeId);
if (!picked) continue;
const option = PERSONA_OPTIONS.find((candidate) => candidate.id === picked);
if (!option) continue;
attributes[dimension] = option.id;
labels[dimension] = option.label;
}
return { attributes, labels };
}
function buildPersonaPools(items: WeightedPick<PersonaAttributeId>[], targetSize: number, rand: () => number) {
const pools = new Map<PersonaDimensionId, PersonaAttributeId[]>();
for (const dimension of PERSONA_DIMENSIONS) {
const options = PERSONA_OPTIONS.filter((option) => option.dimension === dimension);
const picks = items.filter((item) => options.some((option) => option.id === item.id));
pools.set(dimension, buildAllocationPool(picks, targetSize, "" as PersonaAttributeId, rand));
}
return pools;
}
function personaAttributesFromPools(pools: Map<PersonaDimensionId, PersonaAttributeId[]>, index: number) {
const attributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>> = {};
const labels: Partial<Record<PersonaDimensionId, string>> = {};
for (const dimension of PERSONA_DIMENSIONS) {
const picked = pools.get(dimension)?.[index];
if (!picked) continue;
const option = PERSONA_OPTIONS.find((candidate) => candidate.id === picked);
if (!option) continue;
attributes[dimension] = option.id;
labels[dimension] = option.label;
}
return { attributes, labels };
}
function personaBiases(attributes: Partial<Record<PersonaDimensionId, PersonaAttributeId>>) {
let trust = 0;
let anxiety = 0;
let participation = 0;
if (attributes.occupation === "occ_self_employed") anxiety += 0.05;
if (attributes.occupation === "occ_student") participation += 0.03;
if (attributes.occupation === "occ_retired") participation += 0.04;
if (attributes.occupation === "occ_professional") trust += 0.02;
if (attributes.education === "edu_graduate" || attributes.education === "edu_bachelor") participation += 0.02;
if (attributes.housing === "housing_officetel") anxiety += 0.03;
if (attributes.family === "family_children") anxiety += 0.02;
if (attributes.family === "family_single") participation -= 0.01;
return { trust, anxiety, participation };
}
function labelOf<T extends string>(items: Array<{ id: T; label: string }>, id: T) {
return items.find((item) => item.id === id)?.label || id;
}
function segmentLabel(trust: number, anxiety: number, participation: number) {
if (anxiety > 0.68) return "생활압박층";
if (trust > 0.6 && participation > 0.55) return "제도참여층";
if (trust < 0.42) return "불신/관망층";
if (participation > 0.64) return "고관여층";
return "중도실용층";
}
function mean(values: number[]) {
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
}
function noise(rand: () => number, span: number) {
return (rand() - 0.5) * span * 2;
}
function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
}
function mulberry32(seed: number) {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}