Forced-Direct Algorithm으로 네트워크 그래프 구현하기

최종수정일: 2015.04.07

1991년 Thomas Fruchterman 과 Edward Reingold가 발표한 'Graph Drawing by Force-Directed Placement'에 소개된 알고리즘으로 2015년 현재 2700여건 이상의 논문에서 인용되고 있으며 d3js와 R등 다양한 시각화 라이브러리에서 사용되는 대중적인 레이아웃 알고리즘이다.
논문제목에 따라 Force-Directed Algorithm 또는 저자 이름에 따라 Fruchterman-Reingold Algorithm 또는 간단하게 F-R 알고리즘이라고도 불리며 Community Structure 분야나 우리에게는 조금 더 익숙한 Social Network 마이닝에서 주로 사용된다.

아래는 F-R 알고리즘을 가지고 간단한 네트워크 시각화를 표현한 것이다.

(Canvas API를 사용했기 때문에 IE10이하에서는 보이지 않습니다.)

알고리즘은 다음과 같이 크게 세부분으로 나눌 수 있다.

  1. 노드끼리 밀어내는 힘(repulsive force)에 의한 변위를 계산하는 부분.
  2. edge로 연결된 노드끼리 당기는 힘(attractive force)에 의한 변위를 계산하는 부분.
  3. 위의 과정에서 구해진 변위를 실제 노드의 포지션에 적용하는 부분.

Step1: Repulsive Forces

F-R 알고리즘에서 각 노드들은 서로 밀어냄으로써 노드끼리 겹치지 않도록 하고 분석에 용이하도록 최적의 간격을 유지한다.
노드 사이의 거리는 Euclidean Distance로 계산하며 노드 사이의 거리가 멀어질 수록 변위또한 작아진다.

              for (i = 0; i < nodes.length-1; i++) {
                nd1 = nodes[i];
                for (r = i+1; r < nodes.length; r++) {
                  nd2 = nodes[r];
                  ex = nd1.x - nd2.x;
                  ey = nd1.y - nd2.y;
                  er = Math.sqrt(ex*ex + ey*ey); //euclidean distance
                  if (er === 0) er = 0.0001;
                  fr = rForce(er);
                  nd1.dx += ex/er*fr;
                  nd1.dy += ey/er*fr;
                  nd2.dx -= ex/er*fr;
                  nd2.dy -= ey/er*fr;
                }
              }
          

여기에서 사용되는 rForce() 는 아래와 같이 구한다.

            rForce = function(er) {
              return (k*k)/er;
            };
          

코드를 보면 k 라는 정체불명의 값이 있는데, 논문에서 k 는 프레임의 넓이(가로 * 세로)를 총 노드의 개수로 나눈 값의 제곱근을 상수 C를 곱한 값에 해당한다.
말로 표현하니 복잡한데 수식을 보면 아래와 같다.

k 의 목적은 노드에 작용하는 힘의 크기를 결정하는데 있다.
k 가 커질수록 노드끼리의 간격이 늘어나고 k 가 작아질수록 노드끼리의 간격이 좁아진다.
k 의 값을 구하는데는 C 의 값의 영향이 큰데, 이는 C 를 정하는 방법을 실험적인(experimentally) 방법, 즉, 여러번 돌려보고 최적의 값을 찾아서 사용하기 때문이다.
개인적으로는 이렇게 할 바에 차라리 k 를 상수로 결정하여 사용하는 방법도 나쁘지 않을 것 같다.
이 코드에서는 k 상수를 50 으로 정의하였다.

Step2: Attractive Forces

이 부분에서는 edge로 연결된 노드끼리 서로 당기는 힘에의한 변위를 계산한다. 이론적으로는 이 과정에서 최종 변위가 결정된다.
edge set은 노드의 쌍으로 구성되어 있으며 edge set을 순회하면서 두 노드의 변위를 계산한다.

            for (i = 0; i < edges.length; i++) {
              nd1 = nodes[edges[i].origin];
              nd2 = nodes[edges[i].target];
              ex = nd1.x - nd2.x;
              ey = nd1.y - nd2.y;
              er = Math.sqrt(ex*ex + ey*ey);
              if (er === 0) er = 0.0001;
              fa = aForce(er);
              nd1.dx -= ex/er*fa;
              nd1.dy -= ey/er*fa;
              nd2.dx += ex/er*fa;
              nd2.dy += ey/er*fa;
            }
          

한가지 놓친점이 있는데 er이 0임을 체크하는 이유는 변위를 계산하는 과정에서 er을 분모로 삼기때문이다.
aForce() 는 아래와 같다.

            aForce = function(er) {
              return (er*er)/k;
            };
          

Step3: Central Forces

이 단계는 논문에는 나오지 않지만 그래프를 중앙에 위치시키기 위해 추가하였다. 아마 F-R을 보완하는 수많은 서브 알고리즘 중에 이런 방식을 사용하는 알고리즘이 있을지도 모르지만 아직까진 보지못했다.
원리는 간단하다. 중앙에 가상의 노드가 있다고 생각하고 이 노드가 모든 노드를 당긴다면 모든 노드는 자연스럽게 중앙으로 모이게 된다.
당기는 변위를 계산하는 방법은 Attractive Force 를 계산하는 것을 응용하면 된다.

            for (i = 0; i < nodes.length; i++) {
              node = nodes[i];
              ex = canvas.width/2 - node.x;
              ey = canvas.height/2 - node.y;
              er = Math.sqrt(ex*ex + ey*ey);
              if (er === 0) er = 0.0001;
              fa = aForce(er);
              node.dx += ex/er*fa;
              node.dy += ey/er*fa;
            }
          

Step4: Apply

이 단계는 최종적으로 변위를 결정해서 실제 노드 위치에 적용한다.

            for (i = 0; i < nodes.length; i++) {
              er = Math.sqrt(nodes[i].dx * nodes[i].dx + nodes[i].dy * nodes[i].dy);
              nodes[i].x += nodes[i].dx / er * Math.min(er, cooling);
              nodes[i].y += nodes[i].dy / er * Math.min(er, cooling);
            }
            cooling = cooling *.95;
          

위에서 사용된 루틴은 결과적으로 변위의 최대치를 제어하여 변위가 급격하게 변하는 것을 방지한다.
Math.min()의 값이 크면 클수록 적용되는 변위의 값도 커지게 되는데 cooling 을 사용함으로써 er의 값이 너무 큰 경우를 막는것이다.
논문에서 cooling은 프레임의 길이의 1/10 로 초기화하여 매 루프마다 0으로 수렴하도록 한다.
이 소스에서는 매번 cooling 값에 0.95를 곱해주는 방법을 사용하였다.