1. $\ell_1$ penalty clustering method

$\ell_1$正则项的聚类效果。这一问题的的目标函数可以写成, \[ \sum_{k=1}^p \left[ \frac{1}{2} \sum_{i=1}^n (\alpha_{ik}-X_{ik})^2 +\lambda \sum_{i<j} w_{ij} \lvert \alpha_{ik}-\alpha_{jk} \rvert \right] = \sum_{k=1}^p f_1(\alpha^k,X^k) \] 其中,$\alpha^k \in \mathbb{R}^n$是$\alpha$的第$k$列。 这样,关于矩阵$X$的最小化问题就等价于$p$个分隔开的最小化子问题, \[ \min_{\alpha \in \mathbb{R}^{n\times p}} f_1(\alpha,X)=\sum_{k=1}^p \min_{\alpha^k \in \mathbb{R}^n} f_1(\alpha^k,X^k) \] 对于每一个子问题,可以使用FLSA path algorithm来解决。详细的理论分析将会在文章的后面部分给出。现在先从实验上有一个大致的直觉印象。在R环境中,执行以下代码。

library(clusterpathRcpp)
x <- c(-3,-2,0,3,5)
df <- clusterpath.l1.id(x)
plot(df)

得到的聚类结果为:

> df
   alpha    lambda row     col gamma norm solver
1    0.6 1.1333333   1 alpha.1     0    1   path
2    0.6 1.1333333   2 alpha.1     0    1   path
3    0.6 1.1333333   3 alpha.1     0    1   path
4    0.6 1.1333333   4 alpha.1     0    1   path
5    0.6 1.1333333   5 alpha.1     0    1   path
6    0.0 0.8333333   1 alpha.1     0    1   path
7    0.0 0.8333333   2 alpha.1     0    1   path
8    0.0 0.8333333   3 alpha.1     0    1   path
9   -1.0 0.5000000   1 alpha.1     0    1   path
10  -1.0 0.5000000   2 alpha.1     0    1   path
11  -3.0 0.0000000   1 alpha.1     0    1   path
12  -2.0 0.0000000   2 alpha.1     0    1   path
13   0.0 0.0000000   3 alpha.1     0    1   path
14   1.0 1.0000000   4 alpha.1     0    1   path
15   1.0 1.0000000   5 alpha.1     0    1   path
16   3.0 0.0000000   4 alpha.1     0    1   path
17   5.0 0.0000000   5 alpha.1     0    1   path

以及plot出来的效果:

很明显,这是一个针对只有一维特征的数据矩阵(向量)的$\ell_1$的聚类算法执行结果。从结果到图形,光看最后那一句肯定是会蒙圈的,至少,我刚看到的时候是晕的。这里面包含了好几个代码上的trick。下面将一一解开。


1.1. R代码分析

把上述代码保存至一个R文件中,并使用调试模式(如果不会,可以自行度娘或勾勾,关键词“R, Rstudio, debug”)。先解决这个图是怎么画出来的吧!这个比较容易点,相对来说更直观的感受到R数据展示(绘图)的强大!导航至plot(df),跟进(step into function “SHIFT+F4”),得到如下代码,

function (x, type = "l", main = "The entire regularization path of optimal solutions for each variable", 
	xlab = expression(paste("location in the regularization path  ", 
		lambda)), ylab = expression(paste("optimal coefficient  ", 
		alpha)), strip = strip.custom(strip.names = TRUE), layout = c(1, 
		nlevels(x$col)), ...) 
{
	xyplot(alpha ~ lambda | col, x, group = row, type = type, 
		layout = layout, xlab = xlab, ylab = ylab, main = main, 
		strip = strip, ...)
}

是不是和想像的不一样?原来clusterpathRcpp重新定义了plot。。。套路太深,有点想回农村,有木有!看到的不一定是你想的那样的,还是too young。。。有了df的结果数据,和plot的逻辑,可以自已去plot的玩了。比如”type=’p’”, 把col去掉,group=row去掉,还可以把xyplot换掉等等。不会玩的可以看R Graph


下面重点来了,这个算法是怎么处理数据的呢?导航至df<-clusterpath.l1.id(x),可以看到clusterpath.l1.id这个函数的具体内容:

function (x, LAPPLY = if (require(multicore)) mclapply else lapply) 
{
  x <- as.matrix(x)
  dfs <- LAPPLY(1:ncol(x), function(k) {
    L <- .Call("join_clusters_convert", x[, k], PACKAGE = "clusterpathRcpp")
    data.frame(L[1:2], row = factor(L$i + 1), col = factor(k), 
      gamma = factor(0), norm = factor(1), solver = factor("path"))
  })
  df <- do.call(rbind, dfs)
  if (!is.null(rownames(x))) 
    levels(df$row) <- rownames(x)
  levels(df$col) <- alphacolnames(x)
  d <- structure(df, data = x, class = c("l1", "clusterpath", 
    "data.frame"))
  unique(d)
}

可以看出,clusterpath.l1.id调用了lapply这个函数,而这个函数的主要作用是,对第一个参数1:ncol(x)的每一个成员执行function(k),这里k是形参,接收1:ncol(x)中的每一个具体值,处理完后返回一个和1:ncol(x)维度一致的结果向量。1:ncol(x)显然是一个数字序列,代表数据集x的每一列。而function(k)则是对每一列数据按特定的逻辑处理。这一逻辑正是C++函数join_clusters_convert。继续跟进join_clusters_convert,了解这个逻辑是怎么处理的?想法是好的,现实是残酷的。如果是matlab,这是可能的,但在R环境下,实在是没找到怎么调试的办法。或许我太嫩,调试方法羞于见我也说不定!有知道的GGJJDDMM可以联系我啊,万分感谢!直接调试,不太可能,辣么,可不可以在C++环境中调试呢?答案是”当然可以!“。可见,前期工作也没白费,正好派上用场。


下面就快速用codeblocks搭建好环境,当然是选择ubuntu啦!没有为什么,就特么用的爽!具体步骤可以参考Cpp invoke R library。 环境搭建好之后,来看一看我们的测试代码。

#include <iostream>
#include "interface.h"

#include <Rcpp.h>
#include <RInside.h>
#include <iomanip>

using namespace std;

int main(int argc, char* argv[])
{
    RInside R(argc,argv);
    std::string txt2 =
        "suppressMessages(library(clusterpathRcpp))";
    R.parseEvalQ(txt2);
    Rcpp::NumericVector dt((SEXP) R.parseEval("dt<-c(-3,-2,0,3,5)")); //iris[,1]"));
    R["res1"]=join_clusters_convert(dt);  //SEXP类型可以和R中的对象互转

    std::string txt3=
        "cat('\nres1=\n'); print(res1)";//[1:200,])";
    R.parseEvalQ(txt3);
    return 0;
}

是不是相当的easy? 程序猿都是活雷锋,RInside, Rcpp, 简直是太TM好用了!若对代码有疑问,请移步Cpp invoke R library


1.2 C++代码分析

先来看一下涉及到的数据结构:

struct Cluster;
// Doubly-linked list of clusters
typedef std::list<Cluster*> Clusters;
// events are represented as a map, thus e->first is lambda and
// e->second is the pointer to the top cluster of the merge event.
typedef std::multimap<double,Clusters::iterator> Events;
typedef std::pair<double,Clusters::iterator> Event;

struct Cluster {
  int total;
  double v;
  std::vector<int> i;//row indices and cluster size
  double alpha;
  double lambda;
  Cluster *child1;
  Cluster *child2;
  Events::iterator merge_with_next;
};

下面先来分析join_clusters_convert的内容

RcppExport SEXP join_clusters_convert(SEXP xR){
  NumericVector x(xR); //接收R传递的数据集,并交给Rcpp::NumericVector对象。
  Cluster *tree=make_clusters_l1(&x[0],x.length()); //使用l1正则项聚类
  SEXP L=tree2list(tree);
  delete_tree(tree);
  return L;
}

跟进make_clusters_l1

Cluster* make_clusters_l1(double *x, int N){
  double v,sign;
  Cluster *c,*next;  //类簇指针变量
  Clusters::iterator it,next_it,other; //list迭代器,访问clusters内容。
  Clusters clusters;  //类族集,是个std::list.
  
  //初使化类族集,数据集当前维度的每一行都是一个类族。
  //所有的类簇存放进clusters
  //clusters是一个std::list
  //create new list of clusters for this dimension
  for(int i=0;i<N;i++){
    c = new Cluster;   //结构可以new
    c->alpha=x[i];    //当前类簇的中心值
    (c->i).push_back(i);  //行号及统计类簇个成员数
    c->lambda=0.0;
    c->child1=NullCluster;
    c->child2=NullCluster;
    clusters.push_back(c);//constant
  }
  

  //std::cout<<"before sort:\n";
  //print_clusters(clusters);
  //排序,根据alpha值大小。
  clusters.sort(compare_alpha);
  //std::cout<<"after sort:\n";
  //print_clusters(clusters);

  //合并相同alpha值的类簇
  //因为排好序,所以可以相邻比较。
  next_it=it=clusters.begin();
  next_it++;
  while(next_it!=clusters.end()){
    c=*it;
    next=*next_it;
    // check if it and next_it have the same alpha value
    if(next->alpha == c->alpha){
      //then merge the 2
      c->i.insert(c->i.end(),next->i.begin(),next->i.end());
      next_it=clusters.erase(next_it);  //返回值为指向擦除对象的next值
    }else{
      next_it++;
      it++;
    }
  }

  std::cout<<"\nafter merge\n";
  print_clusters(clusters);

  // calculate cluster velocities for identity weights. first element
  // is smallest alpha value, with N-1 other clusters above it. thus
  // it has a velocity of N-1. Next cluster up has N-2 clusters above
  // and 1 cluster below = velocity of N-3, etc. UNLESS there are
  // multiple points with the same exact value, in which case we need
  // to scale the velocity by cluster size.
  for(it=clusters.begin();it!=clusters.end();it++){
    (*it)->total = (*it)->i.size();
    v=0.0;
    sign=-1.0;
    for(other=clusters.begin();other!=clusters.end();other++){
      if(other==it){
	sign=1.0;
      }else{
	v += sign * (*other)->i.size();
      }
    }
    //TDH alternate parameterization
    (*it)->v = v;
    //(*it)->v = v/((double)N-1);
  }
  //print_clusters(clusters);
  return join_clusters(clusters);
}

上述代码的velocity输出为,

alpha lambda velocity
-3 0 4
-2 0 2
0 0 0
3 0 -2
5 0 -4

这里的velocity表示的是最优条件下,类簇内的均值对lambda的变化率(斜率,速率)。

对于velocity的求法,可以参考以下lamma.

Lemma 1. Let $C=\{i:\alpha_i=\alpha_C\} \subseteq \{1,…,n\}$ be the cluster formed after the fusion of all points in $C$, and let $w_{jC}=\sum_{i\in C} w_{ij}$. At any point in the regulariation path, the slope of its coefficient is given by, \[ v_C = \frac{d\alpha_C}{d\lambda}=\frac{1}{\lvert C\rvert}\sum_{j\notin C}w_{jC} \textrm{sign}(\alpha_j-\alpha_C) \]

proof. Consider the following sufficient optimality condition, for all $i=1,…,n$: \[ 0=\alpha_i - X_i + \lambda \sum_{\substack{j\neq i \ \alpha_i\neq \alpha_j}} w_{ij} \textrm{sign}(\alpha_i - \alpha_j) + \lambda \sum_{ \substack{j\neq i\ \alpha_i =\alpha_j} } w_{ij}\beta_{ij} \] with $\lvert \beta_{ij}\rvert \leq 1$ and $\beta_{ij}=\beta_{ji}$. We can rewrite the optimality condition for all $i\in C$: \[ 0=\alpha_i - X_i + \lambda \sum_{j\notin C} w_{ij} \textrm{sign}(\alpha_C - \alpha_j) + \lambda \sum_{i\neq j \in C } w_{ij}\beta_{ij} \] Furthermore, by summing each of these equations, we obtain the following: \[ \alpha_C = \bar{X}_C + \frac{\lambda}{\lvert C \rvert} \sum_{j\notin C} w_{jC}\textrm{sign}(\alpha_j -\alpha_C) \] where $\bar{X}_C=\sum_{i\in C} X_i /\lvert C\rvert$. Taking the derivative with respect to $\lambda$ gives us the slope $v_C$ of the coefficient line for cluster $C$, proving the Lamma.

还有一个参考的定理:随着$\lambda$的增大,不会出现类簇分裂的情况。这也是一维flsa在$\lambda_1$为0时的情况。

Theorem 1 Let $\beta_k(\lambda_2)$ be the optimal solution to the one-dimensional FLSA problem for coefficient $k$ and penalty parameter $\lambda_2$. Then if for some $k$ and $\lambda_2^0$ it holds that $\beta_k(\lambda_2^0)$, then for any $\lambda_2 >\lambda_2^0$ it holds that $\beta_k(\lambda_2)=\beta_{k+1}(\lambda_2)$.

证明过程可以看,

Friedman et al. PATHWISE COORDINATE OPTIMIZATION [2007]

现在开始看join_clusters的代码:

Cluster* join_clusters(Clusters clusters){
  int ni,nj;
  Clusters::iterator it,prev_it,next_it;   //访问
  Events events;
  // set to 0 here to avoid compiler warnings.
  Cluster *c=0,*ci,*cj,*prev_cluster=0;
  Event e;
  Events::iterator e_it,new_event;
  double lambda;
  for(prev_it=clusters.begin(),it=clusters.begin(),it++;
      it!=clusters.end();
      it++,prev_it++){
    c=*it;
    prev_cluster=*prev_it;
    // calculate initial events list from intersection of all adjacent
    // lines
    lambda=(prev_cluster->alpha - c->alpha)/(c->v - prev_cluster->v);  //分析见下文
    if(lambda>0){//only insert if greater than 0!
      e=Event(lambda,prev_it);
      e_it = events.insert(e);//logarithmic
      prev_cluster->merge_with_next = e_it;
    }
    prev_cluster=c;
  }

  while(events.size()>0){
    do{
      e_it=events.begin();//constant
      lambda=e_it->first;
      // follow links and define current clusters
      it=next_it=e_it->second;
      ci= *it;
      ++next_it;
      cj= *next_it;
      ni=ci->i.size();
      nj=cj->i.size();

      //print_events(events);
      //print_clusters(clusters);

      //Make the new cluster object
      c = new Cluster;
      c->i.reserve(ni+nj);
      c->i.insert(c->i.end(),ci->i.begin(),ci->i.end());
      c->i.insert(c->i.end(),cj->i.begin(),cj->i.end());
      c->total=ci->total+cj->total+c->i.size();
      //calculate new velocity of joined lines
      c->v=(ni*ci->v + nj*cj->v)/(ni+nj);
      //store other data...
      c->lambda=lambda;
      c->alpha= ci->alpha + (c->lambda - ci->lambda)*ci->v;
      c->child1=ci;
      c->child2=cj;

      //set up pointers to neighboring clusters
      prev_it=e_it->second;
      prev_it--;
      ++next_it;

      //We have prev-ci-cj-next, we remove ci==it from the list (erase)
      //and set cj==it after to a pointer to new cluster
      it=clusters.erase(it);//linear on the number of elements erased==constant
      *it=c;

      if(it!=clusters.begin()){//constant
	prev_cluster= *prev_it;
	insert_new_event(&events,c,prev_cluster,prev_it,prev_cluster);
      }
      if(next_it != clusters.end()){//constant
	insert_new_event(&events,c,*next_it,it,cj);
      }
      events.erase(e_it);//constant
    }while( events.begin()->first == lambda );
  }
  return c;
}

c->v=(ni*ci->v + nj*cj->v)/(ni+nj);合并类簇时对velocity的理解。 \[ v_c = \frac{1}{\lvert c_i \cup c_j \rvert }\sum_{j \notin (c_i \cup c_j )} w_{j(c_i \cup c_j )}\text{sign}(\alpha_j -\alpha_{(c_i \cup c_j )}) \] 对于任意类簇$(c_k, k\neq i,j)$,(所有的类簇已排好序)。 \[ \sum_k w_{kc} \text{sign}(\alpha_k -\alpha_c)=\sum_k w_{kc1} \text{sign}(\alpha_k -\alpha_{c1}) + \sum_k w_{kc2} \text{sign}(\alpha_k -\alpha_{c2}) \] 对于需要合并的类簇来说, \[ \sum_{i\in I} w_{iJ} \text{sign}(\alpha_I -\alpha_J) + \sum_{j\in J} w_{jI} \text{sign}(\alpha_J -\alpha_I) ==0 \] 特别要说明的是,$w_{iJ}$的$J$是一个类簇集合,$i$代表类簇”I”的每一个无素。所以,$\sum_{i\in I}w_{ij}=\sum_{j\in J}w_{ji}$。 显然这两项的符号是相反的。故,这句代码是合理的。

对于$\lambda$的取值说明:由上面的定理可以知道,唯一可以改变融合两个类簇的的方式是合并两个集合,因为对于增大的$\lambda$,这些类簇不会再次分裂。因此,路径方案是一个分段线性的,并且对于增加的$\lambda$当两个类簇融合时,断点就会出现。这样,就很容易计算下一个断点,当相邻集合具有相同的类簇中心值($alpha$),断点就会出现。翻译不好,还是直接贴英文。In order to do this, define, \[ h_{i,i+1}(\lambda_2)=\frac{\beta_{F{i}}(\lambda_2)-\beta_{F_{i+1}}(\lambda_2)}{ \frac{\partial \beta_{F_{i+1}}}{\partial \lambda_2}-\frac{\partial \beta_{F_{i}}}{\partial \lambda_2} } +\lambda_2 \] which is the value for $\lambda_2$ at which the coefficients of the sets $F_i$ and $F_{i+1}$ have the same value and can be fused, assuming that no other coefficients become fused before that. If $h_{i,i+1}(\lambda_2) <\lambda_2$, these values are being ignored as the two groups $F_i$ and $F_{i+1}$ are actually moving apart for increasing $\lambda_2$. The next value at which coefficients are fused is therefore the hitting time \[ h(\lambda_2)=\min_{h_{i,i+1}>\lambda_2} h_{i,i+1}(\lambda_2) \]


A. STL 简介

STL(Standard Template Library)是C++的一个标准模板库,现在作为C++的一部分嵌入到编译器,所以只要有C++编译器,那么都可以使用STL。该库集成了诸多常用的基本数据结构与算法,高度体现了可复用性。

STL将代码抽象为三个主要的类别:container(容器)、algorithm(算法)、iterator(迭代器)。

网上有一个比较通俗的说法。容器相当于是装东西的东西,如装水的杯子。算法相当于对容器内的东西进行一种操作,如往杯子里倒水。迭代器相当于操作杯子的水的载体,如向水杯里倒水时的水壶,汤勺等。


A.1 容器

容器主要用于存放数据。可以理解为一种数据结构。在实际应用中,根据所处理的数据类型,选择合适的容器。STL 中的容器有顺序容器和关联容器,容器适配器(congtainer adapters :stack,queue ,priority queue ),位集(bit_set )串包(string_package) 等等。 以下是一些常用的容器。

顺序容器:

  • vector, 连续存储的无素
  • list, 双线性列表,只能顺序访问
  • deque, 双队列

关联容器:

  • set, 集合
  • multiset, 允许有相同元素的集合
  • stack, 栈,先进后出
  • queue, 队列,先进先出
  • map, 映射,由{键,值}对组成的集合
  • multimap, 允许键对有相等的次序的映射

A.2 算法

STL中算法大部分不作为某些特定容器类的成员函数,它们是泛型的。这就说明了算法可以适配大部分的容器。泛型算法有4类基本的类型。

  • 变序型队列算法
  • 非变序型 队列算法
  • 排序算法
  • 搜索及通用数值算法 特别说明:STL的算法并不只是针对STL容器所设计,对一般的容器也是适用的。

  • 变序型算法
    这类算法会改变数据在容器内的位序。主要有,
    • copy(复制)
    • swap(交换)
    • replace(替代)
    • clear(清除)
    • remove(移动)
    • reverse(翻转)

其它具体的算法可以到查看C++ reference.

A.3 迭代器

迭代器实际上是一种泛化指针。如果一个迭代器指向了容器中的某一成员,那么迭代器可以通过自增自减来遍历容器中的所员。迭代器是联系容器和算法的媒介,是算法操作容器的接口。STL定义了6种迭代器。

  • 输入迭代器
  • 输出迭代器
  • 前向迭代器
  • 双向迭代器
  • 随机访问迭代器
  • 流迭代器