Compare commits

...
8 Commits
58 changed files with 972 additions and 185 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

+36
View File
@@ -0,0 +1,36 @@
# 1. Hook
**예시**
> Big-O 시간 복잡도의 한계를 넘어,
알고리즘의 세부 작업 실행 횟수와 단위 실행 시간을 고려한
가장 정확한 Dijkstra 알고리즘 런타임 예측 모델
짧은 Hook
# 2. 프로젝트 소개
**예시**
> 네비게이션, 자율주행, 우주항공 등 많은 현대 기술들에게 최적 경로를 탐색하는 것은 중요한 일이며, Dijkstra 알고리즘은 최적 경로를 도출하는 가장 효율적인 알고리즘이다. \
그러나 자율주행과 같이 실시간성과 속도가 중요한 문제가 많아지자, 휴리스틱 기반의 A* 알고리즘들이 적용되었다. 이들은 속도는 빠르지만, 항상 최적해를 보장하지는 않한다는 트레이드오프가 존재한다. \
상황에 따라 적절한 알고리즘을 선택하면 될 듯 하지만, Big-O 시간복잡도는 실행 상황이 아닌 알고리즘만을 고려하기 때문에 Dijkstra의 실행시간이 충분한지 알기 어렵다. \
그래서 **"이 트레이드오프를 감이 아닌 숫자로 계산해서 상황에 따른 정확한 알고리즘을 선택할 수는 없을까?"**라는 질문에서 출발하여 입력 그래프와 기기 성능만으로 Dijkstra의 실제 실행시간을 예측하는 모델을 연구했다.
IB 소논문 활동에서 시작해 계속 발전시켜 현재는 외부 학술지에도 기제된 연구로, 목표는 그래프의 노드 수, 간선 수, 가중치의 분산, 기기 성능만으로 이집 힙이 적용된 Dijkstra의 실행시간을 예측하는 것이다.
- 제목
- 배경
- 문제 제시
- 개인적 연결 (내가 관심을 갖게 된 이유)
- 활동 부가 설명 (육하원칙으로)
# 3. 시행착오
- 제목
- 문제
- 해결
# 4. 결과
- 결과
- 연결
+565
View File
@@ -0,0 +1,565 @@
#import "template.typ": *
// ==================================================
// Config Variables
// ==================================================
#show: base.with(
cur_domain: 1,
total_domain: 5,
domain_name: "Research",
domain_detail: "본질을 향한 여정",
date: "2026",
author: "Seungjun Lee",
main-hue: 60deg,
)
// ==================================================
// Main content
// ==================================================
#cover()
#counter(page).update(1)
#table_of_contents()
#domain_frame(
bg_img_path: "assets/research/bg.jpg",
cover_color: rgb("0F0F0F").transparentize(40%),
font_color: color.oklch(95%, 0.01, 250deg),
chrome_color: color.oklch(80%, 0.01, 250deg),
title: [본질을 향한 여정],
)[
나는 세상을 하나의 질문으로 이해하고 싶었다. \
답을 알수록 새로운 질문이 늘어난다는 것도, \
질문들이 결국 하나로 이어지지 않는다는 것도 나중에 알았다.
생명 수업에서 인체를 배울 사실을 가장 선명하게 느꼈다. \
나는 인간이면서도 안에서 무슨 일이 일어나는지 인지하지 못한다. \
신경계가, 세포가 어떻게 상호작용하는지 하나의 그림으로 설명하려 할수록 \
설명은 흐려졌다.
전체를 통째로 이해하려는 시도는 매번 그렇게 무너졌다. \
대신 남은 방법은 하나였다 쪼개서 이해하고, 이해들을 다시 쌓아 올리는 것. \
전체가 아니라 부분에서, 이론이 아니라 실제 관찰에서 다시 시작하는 것.
이후의 모든 연구는 하나의 발견에서 출발한다. \
그래프 알고리즘의 이론적 시간복잡도가 실제 실행 시간과 어긋나는 지점에서, \
사회 현상을 하나의 법칙이 아니라 여러 변수의 상호작용으로 다시 때, \
그리고 인간의 선택이 이론적 모형을 벗어나는 순간에서 \
나는 같은 방법을 반복했다. 쪼개고, 어긋난 지점을 찾고, 다시 쌓아 올렸다.
]
#activity_cover(
name: [Dijkstra Runtime Analysis],
hook: [Big-O가 버린 세부 실행비용을 다시 세우고, 실행해야만 있던 감소 횟수를 그래프 특성만으로 구해, 이진 Dijkstra의 런타임 변동 96.9%를 실행 전에 설명한 모델.],
)[
네비게이션, 자율주행, 우주항공 많은 현대 기술들에게 최적 경로를 탐색하는 것은 중요한 일이며,
Dijkstra 알고리즘은 최적 경로를 도출하는 가장 효율적인 알고리즘이다. \
그러나 자율주행과 같이 실시간성과 속도가 중요한 문제가 많아지자, 휴리스틱 기반의 A\* 알고리즘들이 적용되었다. 이들은 속도는 빠르지만, 항상 최적해를 보장하지는 않한다는 트레이드오프가 존재한다. \
상황에 따라 적절한 알고리즘을 선택하면 될 듯 하지만, Big-O 시간복잡도는 노드·간선 수 등 입력 크기에 따른 점근적 증가율만 나타낼 뿐, 상수항이나 그래프의 특성, 기기 성능 같은 요인은 반영하지 않기 때문에 Dijkstra의 실제 실행시간이 충분한지 알기 어렵다. \
그래서 *"이 트레이드오프를 감이 아닌 숫자로 계산해서 상황에 따른 정확한 알고리즘을 선택할 수는 없을까?"*라는 질문에서 출발하여 입력 그래프와 기기 성능만으로 Dijkstra의 실제 실행시간을 예측하는 모델을 연구했다.
IB 소논문 활동에서 시작해 계속 발전시켜 현재는 외부 학술지에도 기제된 연구로, 목표는 그래프의 노드 수, 간선 수, 가중치의 분산, 기기 성능만으로 이집 힙이 적용된 Dijkstra의 실행시간을 예측하는 것이다.
]
// #activity_intro(
// title: "Big-O가 답하지 못하는 것",
// )[
// 네비게이션, 자율주행, 우주항공 등 많은 현대 기술들에게 최적 경로를 탐색하는 것은 중요한 일이며,
// Dijkstra 알고리즘은 최적 경로를 도출하는 가장 효율적인 알고리즘이다. \
// 그러나 자율주행과 같이 실시간성과 속도가 중요한 문제가 많아지자, 휴리스틱 기반의 A\* 알고리즘들이 적용되었다. 이들은 속도는 빠르지만, 항상 최적해를 보장하지는 않한다는 트레이드오프가 존재한다. \
// 상황에 따라 적절한 알고리즘을 선택하면 될 듯 하지만, Big-O 시간복잡도는 노드·간선 수 등 입력 크기에 따른 점근적 증가율만 나타낼 뿐, 상수항이나 그래프의 특성, 기기 성능 같은 요인은 반영하지 않기 때문에 Dijkstra의 실제 실행시간이 충분한지 알기 어렵다. \
// 그래서 *"이 트레이드오프를 감이 아닌 숫자로 계산해서 상황에 따른 정확한 알고리즘을 선택할 수는 없을까?"*라는 질문에서 출발하여 입력 그래프와 기기 성능만으로 Dijkstra의 실제 실행시간을 예측하는 모델을 연구했다.
// IB 소논문 활동에서 시작해 계속 발전시켜 현재는 외부 학술지에도 기제된 연구로, 목표는 그래프의 노드 수, 간선 수, 가중치의 분산, 기기 성능만으로 이집 힙이 적용된 Dijkstra의 실행시간을 예측하는 것이다.
// ]
#activity_table()
#let RS = math.italic("RS")
#let RSR = math.italic("RSR")
#activity_process(
title: [*통제된 환경*],
hook: [통제된 환경에서 벗어나다.],
)[
#problem()[
실행 시간 측정의 신뢰도와 타당도를 높이기 위해서는 일정한 환경을 유지하는 것이 중요하다.
그러나 일반적인 데스크탑이나 노트북 환경에서는 통제된 환경을 구현하기 어렵다.
]
#hl[]
#solution()[
운영중인 홈서버의 Proxmox VE에 특정한 스펙의 Debian CT를 만들어 환경을 통제한다.
]
#figure(
image("assets/research/dijkstra/pve.png"),
caption: [CPU: AMD Ryzen 7 7700 8-Core Processor (4cores, Host) \/ RAM: 8GB \/ \
OS: Debian GNU/Linux 13 (trixie) \/ Kernal: Linux 6.8.12-17-pve \/
Python: Python 3.12],
)
]
#activity_process(
title: "Big-O의 한계를 넘어서다",
)[
#problem()[
`Big-O` 시간복잡도는 최고차항을 제외한 모든 항들을 무시하고, 계수마자 없앤다. \
그렇기 때문에 시간복잡도가 $O(n^2)$ 알고리즘에 $n$ 넣는다고 실행시간을 구할 없다.
]
#hl[]
#solution()[
Dijkstra 알고리즘을 의사코드를 기반으로 세부 작업들로 쪼개어 실행시간 식을 도출한다.
]
#block_equation(description: [
$V$ : 노드 수, $E$ : 간선 수, $RS$ : 감소 실행 횟수
])[
$
"Runtime" & = "Heap build" + "Min-node find" + "Vertex Iterate" + "Decrease key" \
& = "V" dot k_1 log(V) + V dot k_2 log(V) + E dot k_3 + RS dot k_4 log(V) \
& = V dot k_12 log(V) + E dot k_3 + RS dot k_4 log(V)
$
]
이진 힙이 적용된 Dijkstra 알고리즘은 크게 위의 4개의 동작으로 나눌 있다. \
각각의 시간 복잡도와 계수 그리고 실행 횟수의 곱을 모두 더하면 전체 실행 시간이 나온다. \
이때, 항의 문자 부분이 같기 때문에 인수분해를 통해 식을 합친다.
이때 개별 항의 계수를 구하는 것이 목적이 아니기 때문에 계수를 합쳐 $k_12$라고 한다.
식을 통해 $V, E, RS, k_12, k_3, k_4$ 알면 Dijkstra의 실행시간을 구할 있게 된다.
]
#activity_process(
title: "존재하지 않는 데이터를 만들다",
)[
#problem()[
식을 학습시키려면 방대한 양의 실제 경로 데이터가 필요했다.
그러나 현실에서는 학습에 필요한 만큼의 실제 경로 데이터를 구할 없다.
]
#hl[]
#solution()[
실제 경로 데이터의 특성들(크기, 밀도, 가중치 분포 등)을 분석하여, 이를 토대로 가상의 경로 데이터를 제작한다.
]
#figure(image("assets/research/dijkstra/hist_original.png", width: 100%), caption: [원데이터 분포])
#grid(
columns: (auto, auto),
figure(
image("assets/research/dijkstra/hist_log.png"),
caption: [$log$ 데이터 Q-Q 플롯],
),
figure(
image("assets/research/dijkstra/Q-Q_log.png"),
caption: [$log$ 데이터 Q-Q 플롯],
),
)
원데이터의 분포와 Q-Q 플롯을 통해 데이터가 양의 왜도를 지닌 것을 파악하여, 가중치에 $log$ 취한 다시 Q-Q 플롯을 그리니 정규성을 보였다.
#figure(
table(
columns: (auto, auto),
[*그래프 밀도*], [$tilde.eq 1.02 dot 10^(-07)$],
[*가중치 평균*], [$tilde.eq 2950$],
[*표준편차 평균*], [$tilde.eq 4070$],
),
caption: [실제 경로 데이터 분석 ],
)
이를 바탕으로 그래프의 밀도와 가중치에 대한 추가적인 특성들을 분석하였다.
#figure(
table(
columns: (auto, auto),
[*노드 수*], [${2000 dot 2^k | k = 0, 1, 2, 3}$],
[*그래프 밀도*], [${10^k, 3 dot 10^k | k = -7, -6, ..., -1}$],
[*표준편차*], [${1000, 1500, 2000, 3000, 4000, 6000, 8000, 12000, 16000}$],
[*개수*], [$10$],
[*가중치 평균*], [$3000$],
[*시작 노드*], [$0$],
[*랜덤 시드*], [$42$],
),
caption: [데이터 생성 매개변수 조합],
)
분석 값들을 바탕으로 적절한 노드 수, 그래프 밀도, 가중치 분산 3개 변수의 쌍으로 로그 정규분포를 띄는 가상 그래프를 864,000개 생성했다.
#figure(
image("assets/research/dijkstra/synthetic_data_code.png"),
caption: u_link(
"https://gitea.seung6lee.com/seung6lee/dijkstra-runtime-analysis/src/branch/main/codes/experiments/synthetic_data/graph_generators/outdegree_graph_generator.py",
)[outdegree 기반 랜덤 경로 데이터 생성 프로그램],
)
#figure(
image("assets/research/dijkstra/synthetic_data.png"),
caption: u_link(
"https://gitea.seung6lee.com/seung6lee/dijkstra-runtime-analysis/src/branch/main/codes/results/real_data/raw/dimacs_s42.csv",
)[가상 경로 데이터 864,000개],
)
]
#activity_process(
title: "실행해야만 알 수 있는 값",
)[
#problem()[
모델을 학습시켜 가중치 $k$들을 알아낼 있다.
그리고 $V$ $E$ 그래프에서 쉽계 계산할 있다.
그러나 $RS$ Dijkstra를 실행하지 않으면 없다.
예측을 위해 실행이 선행되어야 하는 모순이 발생하는 것이다.
]
#hl[]
#solution()[
그래프의 특성만으로 Dijkstra 실행 없이 $RS$ 예측하는 방법을 찾는다.
]
#steps(
(
[$bold(RS)$ 재정의],
[
$RS$ 자체가 아니라, 다른 변수들로 쪼개어서 정의한다.
#block_equation()[
$ RS = "Relax Attemps" times "Relax Success Ratio" = E times RSR $
]
시도 횟수는 $E$라는 것을 알기에, $RSR$ 예측하면 된다. \
$RSR$ 비율이기에 절대량이 아닌 밀도나 분산 등에 영향을 받을 것이라고 판단한다.
#fill_break()
],
),
(
[$bold(RSR) thin \& thin bold(sigma)$],
[
#figure(
image("assets/research/dijkstra/sigma_rsr_01.png", width: 60%),
caption: [$V = 2000, thin D = 0.01$ 에서의 $sigma "vs" RSR$],
)
$(V, D)$ 쌍에 대해 $(sigma, RSR)$ 그래프를 그린 결과,
대부분의 경우 완벽한 로그 함수 관계를 보인다.
#grid(
columns: (1fr, 1fr),
gutter: 1em,
figure(
image("assets/research/dijkstra/sigma_rsr_02.png", width: 100%),
caption: [$V = 2000, thin D = 3 dot 10^(-5)$ 에서의 $sigma "vs" RSR$],
),
figure(
image("assets/research/dijkstra/sigma_rsr_03.png", width: 100%),
caption: [$V = 2000, thin D = 0.0003$ 에서의 $sigma "vs" RSR$],
),
)
그러나 $sigma$값에 관계없이 항상 $RSR=1$이거나, 경향성이 전혀 없는 경우도 나타난다.
#block_equation(description: [
$a, b$ 상수
])[
$ RSR = a dot log(sigma) + b $
]
$log$ 관계를 가정하고 위와 같이 $RSR$ $sigma$ 관계를 식으로 나타내었다.
#fill_break()
],
),
(
[$bold(RSR) thin \& thin bold(D)$],
[
#figure(
image("assets/research/dijkstra/density_rsr.png", width: 60%),
caption: [$V = 10000, thin sigma = 0.3246$ 에서의 $D "vs" RSR$],
)
$(V, sigma)$ 쌍에 대해 $(D, RSR)$ 그래프를 그린 결과,
초반에 급격하게 감소하다 완만해지고 꼬리가 길게 남는 개형을 보인다. \
이는 멱법칙의 특성과 부합하여, $(log(D), log(RSR))$ 그래프를 그려본다.
#figure(
image("assets/research/dijkstra/density_rsr_loglog.png", width: 60%),
caption: [$V = 10000, thin sigma = 0.3246$ 에서의 $log(D) "vs" log(RSR)$],
)
특정 임계점을 기준으로 왼쪽은 항상 $RSR = 1$ 구간이고, 오른쪽은 변수가 완벽하게 멱법칙을 만족하는 구간이 나온다. \
임계점의 위치와 의미를 설명하려 했으나, 해당 지점의 $D$ $log(D)$ 유의미한 특징을 발견하지 못하였다.
#fill_break()
],
),
(
[$bold(D)$ 분석],
[
$D$ 다른 변수로 대체해보았다. \
방향 그래프에서 $D = E / V(V - 1)$ 나타낼 있고,
평균 차수는 $macron(k) = E \/ V = (V - 1) D$ 나타낼 있다.
$D$대신 $macron(k)$ 대입해 그래프를 그려보았으며,
이때 $V$ 통제 변인으로 그래프에서 상수로 취급된다.
#grid(
columns: (1fr, 1fr),
figure(
image("assets/research/dijkstra/avgdeg_rsr_loglog.png"),
caption: [$V = 10000, thin sigma = 0.3246$ 에서의 $log(macron(k)) "vs" log(RSR)$],
),
figure(
image("assets/research/dijkstra/phase_histogram.png"),
caption: [$macron(k)$ 따른 경로 그래프 양상 분포],
),
)
결과, 임계점이 항상 $macron(k) = 1$ 구간에서 발생한다.
지점은 노드에서 출발하는 간선이 최소 1개씩 있는 경우이다.
현상은 Erdős-Rényi 모델의 3가지 상과 연결된다는 것을 파악했다.
해당 모델은 $G(n,p)$ 그래프를 $n p$ 값에 따라 아임계, 임계, 초임계 구간으로 분류한다.\
$sigma$ 분석에서의 예외 상황들도 아임계 구간과 임계 구간으로 설명이 가능해졌다.
#block_equation(description: [
$c, d$ 상수
])[
$ RSR = c dot macron(k)^d quad (macron(k) > 1) $
]
$macron(k) > 1$ 조건에서 위와 같이 $RSR$ $d$ 관계를 식으로 나타내었다.
#fill_break()
],
),
(
[최종 도출],
[
$sigma$ 분석 과정에서 발견한 Erdős-Rényi 모델과의 연결성을 통해 $d$ 분석 과정에서의 한계를 극복하여, $sigma$ $d$ 각각과 $RSR$간의 식을 확정하였다.
이때 $sigma$ $d$ 그래프에서 서로 독립적이기 때문에 관계식을 곱하면 변수의 영향을 모두 반영하는 식을 도출할 있다.
#block_equation(description: [
$alpha, beta, gamma$ 상수
])[
$
RSR & = {a dot log(sigma) + b} times {c dot macron(k)^d} quad (macron(k) > 1) \
& = macron(k)^d times {a c dot log(sigma) + b c} \
& = macron(k)^alpha times {beta dot log(sigma) + gamma}
$
]
],
),
(
[회귀 분석],
[
#figure(
image("assets/research/dijkstra/rsr_regression.png", width: 50%),
caption: [비선형 회귀 결과 (
$alpha = 0.0612 plus.minus 0.000202, thin
beta = 0.928 plus.minus 0.000171, thin
gamma = -0.711 plus.minus 0.000183$)],
)
제작한 864,000개의 데이터에 대해 비선형 회귀를 진행하였더니 $RSR$ 대해 $R^2 = 0.9947$ 달하는 적합에 성공하였다.
이를 통해 Dijkstra 실행 없이도 $99%$ 정확도로 $RS$ 예측할 있게 되었다.
],
),
)
]
#activity_process(title: "다중공선성")[
#problem()[
#block_equation(description: [
$V$ : 노드 수, $E$ : 간선 수, $RS$ : 감소 실행 횟수
])[
$
"Runtime" = V dot k_12 log(V) + E dot k_3 + RS dot k_4 log(V)
$
]
이제 회귀 분석을 통해 $k_12, k_3, k_4$ 구하면 된다.
그러나 $k_12$ $k_4$ 포함된 항이 유사하다.
공통적으로 $log(V)$ 있으며, $V$ $RS$ 양의 상관관계를 가진다.
따라서 회귀분석 시행하면 다중공선성이 발생할 가능성이 높다.
]
#hl[]
#solution()[
모든 데이터를 한번에 회귀하는 것이 아니라, 고유한 $V$별로 부분 회귀를 하고 결과를 종합하는 방법을 고안했다.
#block_equation()[
$
"UC"_1(V) & = k_12 log(V) \
"UC"_2(V) & = k_4 log(V) \
"Runtime" & = V dot "UC"_1(V) + E dot k_3 + RS dot "UC"_2(V)
$
]
$V = V_i$ 데이터로 회귀를 하면 $"UC"_1(V_i), "UC"_2(V_i), k_3$ 도출된다. \
이때 $"UC"_1(V)$ $"UC"_2(V)$ $V$ 대해 $log$ 관계를 가져야하고, $k_3$ 상수여야한다.
]
]
#activity_process(title: "이론과 결과의 괴리")[
#callout(title: [$bold("UC"_1(V_i)), bold("UC"_2(V_i)), bold(k_3)$ 적합 결과])[
#grid(
columns: (1fr, 1fr),
gutter: 1em,
figure(
image("assets/research/dijkstra/node_uc1.png", width: 100%),
caption: [$"UC"_1(V)$],
),
figure(
image("assets/research/dijkstra/node_uc2.png", width: 100%),
caption: [$"UC"_2(V)$],
),
)
#figure(
image("assets/research/dijkstra/node_k3.png", width: 60%),
caption: [$"k"_3(V)$],
)
]
#problem()[
$"UC"_2(V)$ $V$ 정확한 $log$ 관계를 보였다.\
그러나 $"UC"_1(V)$ 어떠한 경향성도 보이지 않았으며, \
$k_3$ 고정되지 않고 우하향의 추세를 보였다.
]
#hl[]
#solution()[
#figure(
image("assets/research/dijkstra/contribution_vs_N.png", width: 100%),
caption: [세부 작업별 전체 실행시간 차지 비율],
)
$"UC"_1(V)$ 경향성은 파악이 불가능했으나,
전체 런타임에서 $V dot "UC"_1(V)$항이 차지하는 비율이 $V lt.eq.slant 20000$ 지점에서 평균 $4.2 %$ 이하라는 것을 고려해 평균을 대푯값으로 사용하기로 하였다.
$k_3$ 우하향 추세는 파이썬의 캐싱 효과로 인한 것이라고 추정하였다. \
실행시간은 양수여야한다는 점을 고려하여 지수 포화 함수로 모델링하였다.
$"UC"_2(V)$ $log$ 함수 적합을 통해 성공적으로 $k_4$ 구했다.
]
]
// #activity_result(title: "예측할 수 있게 된 것")[
// #block_equation(
// title: [키 감소 횟수],
// description: [$V$ : 노드 수, $E$ : 간선 수, $sigma$ : 가중치 표준편차],
// )[
// $
// RSR(macron(k), sigma) & = macron(k)^0.0612 (0.928 log sigma - 0.711), quad
// macron(k)(V, E) & = E \/ V \
// RS(V, E, sigma) & = E dot RSR(macron(k)(V, E), sigma)
// $
// ]
// 실행해야만 알 수 있던 $RS$를 세 변수만으로 계산할 수 있게 되었다.
// #block_equation(
// title: [단위 실행시간],
// description: [단위 : 초],
// )[
// $
// "UC"_1 & = 2.15 times 10^(-7) \
// k_3(V) & = 1.59 times 10^(-7) e^(-5.67 times 10^(-5) V) + 4.37 times 10^(-8) \
// "UC"_2(V) & = 1.06 times 10^(-6) log_2 V - 1.09 times 10^(-5)
// $
// ]
// 세 값은 모두 $V$의 함수이거나 상수가 되어 식에 남아 있던 미지수가 전부 사라진다.
// #block_equation(
// title: [최종 모델],
// description: [입력 : $V$, $E$, $sigma$],
// )[
// $
// "Runtime"(V, E, sigma) = V dot "UC"_1 + E dot k_3(V) + RS(V, E, sigma) dot "UC"_2(V)
// $
// ]
// 최종 식의 세 변수 모두 실행 전에 그래프에서 직접 추출할 수 있는 값이기에, \
// *Dijkstra를 한 번도 실행하지 않고 실행시간을 예측할 수 있다.*
// ]
// #activity_result(title: "남은 오차")[
// #figure(
// image("assets/research/dijkstra/comparison_plot.png"),
// caption: [실제 경로 데이터에 대한 실행시간 예측과 $RS$ 예측],
// )
// #figure(
// image("assets/research/dijkstra/predicted_vs_real.png", width: 60%),
// caption: [생성 그래프에 대한 예측값과 실측값],
// )
// 내삽에 대해 실행 시간 예측에 대해서 $96.9%$를 설명에 성공했다. \
// 게다가 실제 경로 데이터(외삽)에서도 $RS$예측에 대해 일관된 정확도를 보였다. \
// 그러나 실전 상황에서 그래프가 커질수록 실행시간 예측은 과대평가하는 경항이 보였다.\
// 이 결과들에서 내 연구의 의의와 한계가 뚜렷하게 드러난다. \
// $RS$는 그래프의 구조만으로 결정되므로, 그래프의 근본적인 특성까지 쪼개었더니 정확하게 설명이 가능해졌으나, 실행시간은 파이썬 아래의 캐싱이나 하드웨어적 특성까지 쪼개지 못해 예측과 실측값 차이가 벌어졌다.
// #table(
// columns: (auto, 1fr),
// table.header([*문제 상황*], [*극복 방법*]),
// [학습 데이터의 부재], [분포를 분석해 864,000개를 직접 생성],
// [실행해야 알 수 있는 값], [$E times RSR$로 쪼개 미지수를 하나로 축소],
// [다중공선성], [$V$별 개별 회귀 후 기여도 기준으로 정밀도 배분],
// )
// 모든 문제 상황에서는 직면한 큰 문제를 작은 단위들로 쪼개고, 작은 문제들의 해결책들을 종합하여 전체 문제를 해결해나갔다.
// 실행시간 측정을 위한 통제된 환경은 직접 구축한 홈서버의 가상화 환경(PVE)를 통해 구현했다.
// 이제 남은 오차는 파이썬이라는 언어 아래의 저차원에 위치해 있다. \
// 카이스트에서 언어와 하드웨어 수준의 저차원 요소를 배워 이 모델의 정확도를 끌어올리고, 완성한 모델을 실제 임베디드 시스템에서 알고리즘 선택의 지표로 적용하려 한다.
// #context ref_evidence()
// ]
#add_evidence(
activity_ref: ref(label("Dijkstra Runtime Analysis")),
(
[소스 코드 자료],
u_link(
"https://gitea.seung6lee.com/seung6lee/dijkstra-runtime-analysis",
)[https://gitea.seung6lee.com/seung6lee/dijkstra-runtime-analysis],
),
(
[International Journal of STEAM 기재],
[
#image("assets/research/dijkstra/short_paper_01.png", height: 70%)
#image("assets/research/dijkstra/short_paper_02.png")
],
),
(
[정리 노트],
{
for i in range(1, 15) {
let path = "assets/research/dijkstra/notes/note_" + pad_zeros(i, 2) + ".jpg"
image(path)
}
},
),
(
[논문 본문],
{
for i in range(1, 17) {
let path = "assets/research/dijkstra/paper/paper_" + pad_zeros(i, 2) + ".png"
image(path)
}
},
),
)
#evidence()
+371 -185
View File
@@ -7,7 +7,7 @@
#let font-display = ("Source Serif 4 Display", "Source Han Serif K") #let font-display = ("Source Serif 4 Display", "Source Han Serif K")
#let font-subhead = ("Source Serif 4 Subhead", "Source Han Serif K") #let font-subhead = ("Source Serif 4 Subhead", "Source Han Serif K")
#let font-caption = ("Source Serif 4 Caption", "Source Han Serif K") #let font-caption = ("Source Serif 4 Caption", "Source Han Serif K")
// #let font-code = #let font-code = ("Source Code Pro", "Pretendard")
#let color-surface = white #let color-surface = white
#let color-ink = color.oklch(22%, 0.01, 250deg) #let color-ink = color.oklch(22%, 0.01, 250deg)
@@ -19,6 +19,10 @@
#let bleed = 1.5cm #let bleed = 1.5cm
#let page-paper = "a4"
#let page-height = 297mm
#let page-margin = (x: 2.8cm, y: 4.2cm)
// ================================================== // ==================================================
// Metadata // Metadata
// ================================================== // ==================================================
@@ -48,12 +52,14 @@
), ),
) )
#let evidence-data = state("evidence-data", none)
// ================================================== // ==================================================
// Util functions // Util functions
// ================================================== // ==================================================
#let pad-zeros(num, width) = { #let pad_zeros(num, width) = {
let s = str(num) let s = str(num)
"0" * calc.max(0, width - s.len()) + s "0" * calc.max(0, width - s.len()) + s
} }
@@ -68,41 +74,73 @@
accent-faint: color.oklch(70%, 0.02, hue), accent-faint: color.oklch(70%, 0.02, hue),
) )
#let fill_break() = context {
v(1fr)
}
#let heading_text(it) = { #let heading_text(it) = {
if it.has("text") { if type(it) == str {
it
} else if it == linebreak() {
" "
} else if it.has("text") {
it.text it.text
} else if it.has("children") { } else if it.has("children") {
it.children.map(heading_text).join("") it.children.map(heading_text).join("")
} else if it == linebreak() {
" "
} else { } else {
"" ""
} }
} }
// Must be called from inside a context block.
#let cur_activity() = {
let m = meta.get()
let level1s = query(heading.where(level: 1, outlined: true))
let n = counter(heading).get().at(0)
let cur = level1s.at(n - 1)
let prefix = str(m.cur_domain) + "." + str(n) + ". "
(
prefix: prefix,
name: heading_text(cur.body),
location: cur.location(),
)
}
// ================================================== // ==================================================
// Body components // Body components
// ================================================== // ==================================================
#let callout(bg_color: none, border_color: none, body) = context { #let kicker(color: color-ink-helper, content) = {
set text(
font: font-body,
weight: 700,
size: 10pt,
tracking: 1pt,
fill: color,
)
block(below: 1.5em)[
#content
]
}
#let callout(title: "", bg_color: none, border_color: none, body) = context {
let cp = color-palette.get() let cp = color-palette.get()
let bg_c = if bg_color == none { cp.callout-surface } else { bg_color } let bg_c = if bg_color == none { cp.callout-surface } else { bg_color }
let border_c = if border_color == none { cp.callout-border } else { border_color } let border_c = if border_color == none { cp.callout-border } else { border_color }
let x_pad = 12pt
let y_pad = 18pt
let gap = 24pt
block( block(
width: 100%, width: 100%,
above: gap + y_pad, inset: (x: 12pt, y: 18pt),
below: gap + y_pad, spacing: 1.6em,
outset: (x: x_pad, y: y_pad), // radius: 4pt,
radius: 4pt,
fill: bg_c, fill: bg_c,
stroke: (paint: border_c), stroke: (paint: border_c),
)[ )[
#if title != "" {
kicker(color: cp.accent)[#title]
}
#body #body
] ]
} }
@@ -118,6 +156,7 @@
let body-size = measure( let body-size = measure(
content, content,
width: body-width, width: body-width,
height: size.height,
) )
grid( grid(
@@ -129,7 +168,7 @@
stroke: ( stroke: (
paint: c, paint: c,
thickness: line-width, thickness: line-width,
cap: "round", // cap: "round",
), ),
angle: 90deg, angle: 90deg,
length: body-size.height + 15pt, length: body-size.height + 15pt,
@@ -140,20 +179,6 @@
}) })
} }
#let kicker(color: color-ink-helper, content) = {
set text(
font: font-body,
weight: 700,
size: 10pt,
tracking: 1pt,
fill: color,
)
block(below: 1.5em)[
#content
]
}
#let problem(content) = context { #let problem(content) = context {
let c = color-palette.get().problem let c = color-palette.get().problem
@@ -177,7 +202,9 @@
#let hl(content) = context { #let hl(content) = context {
let cp = color-palette.get() let cp = color-palette.get()
// if content == "" {} let body = box(
inset: (x: if content == [] { 0pt } else { 10pt }),
)[#content]
set text( set text(
font: font-body, font: font-body,
@@ -190,70 +217,74 @@
grid( grid(
columns: (1fr, auto, 1fr), columns: (1fr, auto, 1fr),
align: horizon, align: horizon,
gutter: 10pt, line(length: 100%, stroke: (paint: cp.rule)), body, line(length: 100%, stroke: (paint: cp.rule)),
line(length: 100%, stroke: (paint: cp.rule)), content, line(length: 100%, stroke: (paint: cp.rule)),
) )
} }
#let block_equation(content) = { #let block_equation(title: "", description: "", content) = context {
let cp = color-palette.get()
callout()[ callout()[
#set par(
spacing: 1em,
)
#if title != "" {
kicker(color: cp.accent)[#title]
}
#content #content
#if description != "" {
set text(
size: 9pt,
fill: color-ink-helper,
)
align(right)[#description]
}
] ]
} }
#let stack(layers) = context { #let u_link(url, content) = link(url)[
#underline(
content,
)
]
#let steps(..layers) = context {
let cp = color-palette.get() let cp = color-palette.get()
let radius = 12pt let radius = 12pt
let gutter = 12pt let gutter = 12pt
let gap = 24pt let gap = 24pt
let indent = radius + gutter
for (idx, (title, body)) in layers.enumerate() { for (idx, (title, body)) in layers.pos().enumerate() {
let is_last = idx == layers.len() - 1 let is_last = idx == layers.len() - 1
let content = {
v(radius - 12pt / 2)
text(
weight: 700,
)[
#title
#linebreak()
]
body
v(gap)
}
block( block(
above: if idx == 0 { auto } else { 0pt }, above: if idx == 0 { auto } else { 0pt },
below: if is_last { auto } else { 0pt }, below: if is_last { auto } else { 0pt },
inset: (left: indent),
stroke: (left: (paint: cp.accent, thickness: 1pt)),
width: 100%, width: 100%,
)[ )[
#layout(size => { #place(
let content_width = size.width - radius * 2 - gutter left + top,
let content_size = measure(content, width: content_width) dx: -indent - radius,
)[
#circle(radius: radius, fill: cp.accent)[
#set align(center + horizon)
#set text(font: font-serif, fill: color-ink-inverse, size: 12pt, weight: 700)
#{ idx + 1 }
]
]
grid( #v(radius - 12pt / 2)
columns: (radius * 2, 1fr), #text(weight: 700)[
gutter: gutter, #title
grid( #linebreak()
rows: (radius * 2, auto), ]
circle(radius: radius, fill: cp.accent)[ #body
#set align(center + horizon) #v(gap)
#set text(font: font-serif, fill: color-ink-inverse, size: 12pt, weight: 700)
#{ idx + 1 }
],
line(
start: (50%, 0pt),
end: (50%, content_size.height - radius * 2),
stroke: (
paint: cp.accent,
thickness: 1pt,
cap: "round",
),
),
),
content,
)
})
] ]
} }
} }
@@ -285,6 +316,41 @@
} }
} }
#let ref_evidence(kinds: none) = {
let ca = cur_activity()
set ref(
supplement: "증빙 자료.",
)
align(bottom)[
#hl[]
*활동 증빙 자료:*
#underline[#ref(label("evidence) " + ca.name))]
#if kinds != none {
text(fill: color-ink-helper)[(#kinds)]
}
]
}
#let add_evidence(activity_ref: none, ..content) = context {
let name = str(activity_ref.target)
let data = (
(
name,
content.pos().flatten(),
),
).to-dict()
evidence-data.update(old => {
if old == none {
data
} else {
old + data
}
})
}
#let header_template(dark: false, font_color: none, show_activity: false) = place( #let header_template(dark: false, font_color: none, show_activity: false) = place(
top + left, top + left,
dy: 1cm, dy: 1cm,
@@ -301,16 +367,13 @@
)[ )[
#context { #context {
let m = meta.get() let m = meta.get()
link((page: 1, x: 0pt, y: 0pt))[#m.cur_domain. #m.domain_name] link((page: 2, x: 0pt, y: 0pt))[#m.cur_domain. #m.domain_name]
h(1fr) h(1fr)
if show_activity { if show_activity {
let level1s = query(heading.where(level: 1, outlined: true)) let ca = cur_activity()
let n = counter(heading).get().at(0)
let cur = level1s.at(n - 1)
let prefix = str(m.cur_domain) + "." + str(n) + ". "
link( link(
cur.location(), ca.location,
prefix + cur.body, ca.prefix + ca.name,
) )
} }
} }
@@ -356,7 +419,7 @@
) )
#text(font: font-caption)[ #text(font: font-caption)[
#pad-zeros(m.cur_domain, 2) / #pad-zeros(m.total_domain, 2) #pad_zeros(m.cur_domain, 2) / #pad_zeros(m.total_domain, 2)
] ]
#v(12pt) #v(12pt)
@@ -381,32 +444,73 @@
] ]
} }
// Table of contents templage function // Table of contents template function
#let table_of_contents() = page( #let table_of_contents() = context {
footer: footer_template(), let cp = color-palette.get()
)[ let m = meta.get()
#context {
let m = meta.get()
heading(level: 1, outlined: false, numbering: none)[#m.cur_domain. #m.domain_name]
}
#outline(title: none, depth: 2) page(
] footer: footer_template(),
)[
#show outline.entry.where(level: 1): it => link(it.element.location())[
#block(above: 25pt)[
#grid(
columns: (1fr, auto),
align: horizon,
text(font: font-subhead, size: 15pt, weight: 700)[
#it.indented(it.prefix(), heading_text(it.body()))
],
text(size: 10pt, fill: color-ink-helper)[
#it.page()
],
)
#let desc = it.element.supplement
#if desc != [domain] and desc != [evidence] {
text(fill: color-ink-helper, size: 10pt)[
#it.element.supplement
]
}
#v(10pt)
#line(length: 100%, stroke: (paint: cp.accent-faint, thickness: 0.5pt))
]
]
#heading(level: 1, outlined: false, numbering: none)[#m.cur_domain. #m.domain_name]
#label("table_of_contents")
#outline(title: none, depth: 1)
]
}
// Domain frame template function // Domain frame template function
#let domain_frame(title: "", bg_img_path: none, font_color: color-ink, chrome_color: color-ink-helper, content) = page( #let domain_frame(
background: place( title: "",
center + horizon, bg_img_path: none,
if bg_img_path != none { cover_color: white.transparentize(50%),
image(bg_img_path, width: 100.5%, height: 100.5%, fit: "cover") font_color: color-ink,
}, chrome_color: color-ink-helper,
), content,
) = page(
background: if bg_img_path != none {
stack(
image(bg_img_path, width: 100.5%, height: 100.5%, fit: "cover"),
move(
dy: -50%,
rect(width: 100.5%, height: 100.5%, fill: cover_color),
),
)
} else { none },
header: header_template(font_color: chrome_color), header: header_template(font_color: chrome_color),
footer: footer_template(font_color: chrome_color), footer: footer_template(font_color: chrome_color),
)[ )[
#set text(fill: font_color) #set text(fill: font_color)
#show strong: set text(fill: font_color)
#heading(level: 1)[
#heading(level: 1, supplement: [domain])[
#title #title
] ]
@@ -414,61 +518,152 @@
] ]
// Activity cover template function // Activity cover template function
#let activity_cover(name: "", hook: "", content) = page( #let activity_cover(name: "", hook: "", content) = context {
header: header_template(), let cp = color-palette.get()
footer: footer_template(),
)[ page(
#heading(level: 1)[ header: header_template(),
#name footer: footer_template(),
fill: cp.cover-surface,
)[
#show heading.where(level: 1): set block(above: 0em, below: 25pt)
#heading(level: 1, supplement: hook)[
#name
]
#label(heading_text(name))
#block_quote()[
#hook
]
#v(25pt)
#content
] ]
}
#block_quote()[ #let activity_table() = context {
#hook let cp = color-palette.get()
let ca = cur_activity()
let level1s = query(heading.where(level: 1, outlined: true))
let n = counter(heading).get().at(0)
let sel = heading.where(level: 2, outlined: true).after(ca.location)
if n < level1s.len() {
sel = sel.before(level1s.at(n).location(), inclusive: false)
}
page(
header: header_template(show_activity: true),
footer: footer_template(),
)[
#show outline: it => [
#line(length: 100%, stroke: (paint: cp.accent-faint, thickness: 0.5pt))
#it
]
#show outline.entry.where(level: 2): it => link(it.element.location())[
#set par(
spacing: 1em,
)
#block(above: 15pt)[
#grid(
columns: (1fr, auto),
align: horizon,
text(size: 12pt, weight: 700)[
#it.body()
],
text(size: 8pt, fill: color-ink-helper)[
#it.page()
],
)
#let desc = it.element.supplement
#par(
spacing: 0pt,
leading: 0.8em,
)[
#text(fill: color-ink-helper, size: 10pt)[
#it.element.supplement
]
]
#line(length: 100%, stroke: (paint: cp.accent-faint, thickness: 0.5pt))
]
]
#heading(level: 2, outlined: false)[
시행착오 목차
]
#outline(title: none, target: sel)
] ]
}
#content
]
// Activity intro template function
#let activity_intro(title: "", content) = page(
header: header_template(show_activity: true),
footer: footer_template(),
)[
#kicker("프로젝트 설명")
#heading(level: 2)[
#title
]
#content
]
// Activity process // Activity process
#let activity_process(title: "", content) = page( #let activity_process(title: "", hook: [], content) = page(
header: header_template(show_activity: true), header: header_template(show_activity: true),
footer: footer_template(),
)[ )[
#kicker("시행착오") #kicker("시행착오")
#heading(level: 2)[ #heading(level: 2, supplement: hook)[
#title #title
] ]
#content #content
] ]
// Activity result #let evidence() = context {
#let activity_result(title: "", content) = page( let cp = color-palette.get()
header: header_template(show_activity: true), let ed = evidence-data.get()
)[
#kicker("결론")
#heading(level: 2)[ page(
#title header: header_template(),
footer: footer_template(),
)[
#set heading(
numbering: (..nums) => {
numbering("A", nums.at(1))
},
supplement: [evidence],
)
#show heading.where(level: 2): it => {
let num = counter(heading).display(
(..nums) => numbering("A", nums.at(1)),
)
block[
#num. #it.body
]
}
#show table.cell.where(x: 0): set text(
weight: 700,
fill: cp.accent,
)
#heading(level: 1, numbering: none)[
증빙 자료
]
#let cnt = ed.len()
#for (name, data) in ed [
#heading(level: 2, supplement: "evidence")[#name]
#label("evidence) " + name)
#table(
columns: (10em, 1fr),
..data,
)
#{ cnt -= 1 }
#if cnt > 0 { pagebreak() }
]
] ]
}
#content
]
// ================================================== // ==================================================
// Style Settings // Style Settings
@@ -509,8 +704,8 @@
) )
set page( set page(
paper: "a4", paper: page-paper,
margin: (x: 2.8cm, y: 4.2cm), margin: page-margin,
fill: color-surface, fill: color-surface,
) )
@@ -524,25 +719,58 @@
set par( set par(
leading: 1.2em, leading: 1.2em,
spacing: 2.4em, spacing: 1.8em,
) )
show raw: set text(font: font-code)
show strong: set text(fill: cp.accent) show strong: set text(fill: cp.accent)
// underline config
set underline(
offset: 2pt,
stroke: (paint: cp.accent, thickness: 1pt),
)
// list config // list config
show list: set par(leading: 0.8em) show list: set par(leading: 0.8em)
set list( set list(
marker: ([--], [•]), marker: ([--], [•]),
indent: 0.6em, indent: 0.6em,
body-indent: 0.8em, body-indent: 0.8em,
spacing: 1.2em, spacing: 0.8em,
) )
show enum: set par(leading: 0.8em) show enum: set par(leading: 0.8em)
set enum( set enum(
indent: 0.6em, indent: 0.6em,
body-indent: 0.8em, body-indent: 0.8em,
spacing: 1.2em, spacing: 0.8em,
)
// image config
show image: set align(center)
show image: set par(
spacing: 1.6em,
)
// figure config
show figure: set text(
fill: color-ink-helper,
size: 10pt,
)
show figure.caption: it => {
"[ " + it.body + " ]"
}
// table config
show table: set text(size: 10pt)
set table(
inset: 0.8em,
align: left + top,
stroke: (paint: cp.rule),
) )
// heading config // heading config
@@ -567,48 +795,6 @@
fill: color-ink, fill: color-ink,
) )
// outline config
set outline(
indent: 1em,
)
show outline: set par(leading: 1em)
show outline.entry: it => link(
it.element.location(),
)[
#grid(
columns: (1fr, auto),
it.indented(it.prefix(), heading_text(it.body())), it.page(),
)
]
show outline.entry.where(level: 1): it => {
set text(
font: font-subhead,
size: 15pt,
weight: 700,
)
block(above: 50pt)[
#it
#line(length: 100%, stroke: (paint: color-ink-secondary, thickness: 1pt))
]
}
show outline.entry.where(level: 2): it => {
set text(
font: font-body,
size: 12pt,
weight: 700,
fill: color-ink-secondary,
)
block(above: 20pt)[
#it
]
}
// render content // render content
doc doc
} }