基于Spark ALS在线推荐系统

来源:互联网 发布:程序员初级职称 编辑:程序博客网 时间:2024/04/30 22:47

所用技术:

Bootstrap、flat-ui 、 Servlet、Spark1.4.1、Hadoop2.6.0、JDK
说明:本系统不涉及ssh相关内容,只有简单的Servlet和JSP、HTML页面,系统架构相对简单。

系统部署:

1. 拷贝spark-assembly-1.4.1-hadoop2.6.0.jar到WebContent/WEB-INF/lib目录;
(spark-assembly-1.4.1-hadoop2.6.0.jar文件由原生spark-assembly-1.4.1-hadoop2.6.0.jar删除javax/servlet包获得,由于太大,所以就没有上传了);
2. 拷贝原生spark-assembly-1.4.1-hadoop2.6.0.jar文件到HDFS(目录和代码中一致);
3. 拷贝WebContent/WEB-INF/lib目录中的Spark141-als.jar到HDFS(目录和代码中保持一致);
4. 拷贝Hadoop集群(调用所使用的集群,每个人不一样)配置文件yarn-site.xml到HDFS(目录和代码中保持一致);
5. 修改相关配置文件,由于hadoop相关配置、系统的一些属性需要修改为实际的配置及属性,所以针对这些需要进行修改(后面版本中会对此单独一个配置文件),例如:



系统使用数据为movielens上面的数据,下载地址为:http://grouplens.org/datasets/movielens/ ,本测试使用的数据是:


可以根据自己集群的实际情况选择下载数据集的大小。

代码下载地址:https://github.com/fansy1990/movie_recommend ;

系统界面及相关功能实现

1. 系统首页

系统首页如下图所示

首页直接使用bootstrap的tab界面,分为三栏,分别对应:首页介绍、算法调用和推荐;

2. 初始化后台任务

在启动tomcat的时候,后台会打印相关日志:
[plain] view plain copy
  1. 信息: Starting Servlet Engine: Apache Tomcat/7.0.52  
  2. initial begin...  
  3. 2016-08-23 12:33:28,189 WARN [org.apache.hadoop.util.NativeCodeLoader] - Unable to load native-hadoop library for your platform... using builtin-java classes where applicable  
  4. 2016-08-23 12:33:29,836 INFO [util.Utils] - Movies data size:3883  
  5. 2016-08-23 12:33:33,638 INFO [util.Utils] - Users data size:6040  
  6. initial end!  
  7. 八月 23, 2016 12:33:33 下午 org.apache.coyote.AbstractProtocol start  
这里是初始化的相关打印,初始化使用InitServlet,在里面调用了Utils的init方法,init方法主要初始化了movies变量和userWithRatedMovies变量和allMovieIds变量,各个变量表示意思如下:
  • movies:所有的电影ID和电影所有相关信息的Map映射;
  • userWithRatedMovies:用户ID和当前用户所有评分过的电影ID集合的Map映射;
  • allMovieIds:所有电影ID的Set集合;
这里看到初始化的电影有3883个,而用户数有6040个;

3. 建模前台与后台功能实现

 建模界面如下所示:


用户输入或选择对应的参数,即可点击“建模”,提交Spark ALS任务到YARN(Hadoop集群),进行算法调用。
建模流程:
  1. 用户输入相关算法参数后,点击建模;
  2. 后台RunALS Servlet获取提交的算法参数,封装Spark ALS算法,然后提交给YARN;
  3. YARN在分配了相关资源后,会返回一个任务ID:applicationID,这时启动一个线程,专门获取该applicationId的任务进度,更新全局allAppStatus变量(Map变量<applicationId,任务状态>),后台返回前台此applicationId;
  4. 前台获取到此applicationId后,如果获取的applicationId为Null,那么就会弹出一个模态框提示建模提交任务失败;否则,会弹出一个进度条模态框(此进度条模态框下面会有详细介绍);
  5. 前台启动定时任务,去后台获取全局allAppStatus变量对应applicationId的状态,返回前台,更新进度条模态框对应进度;
  6. 一直到任务成功或失败,给出对应的提示;

3.1进度条模态框实现

1. 进度条模态框div定义如下:
[html] view plain copy
  1. <div class="modal fade" id="myModal1" tabindex="-1" role="dialog"  
  2.                             aria-labelledby="myModalLabel">  
  3.                             <div class="modal-dialog" role="document">  
  4.                                 <div class="modal-content">  
  5.                                     <div class="progress progress-striped active"  
  6.                                         style="margin-bottom: 0px; height: 25px; border-radius: 5px;">  
  7.                                         <div id="progressId" class="progress-bar"  
  8.                                             style="width: 1%; height: 100%;">0%</div>  
  9.                                     </div>  
  10.                                 </div>  
  11.                             </div>  
  12.                         </div>  
全部使用bootstrap的基本样式;
2. 弹出该模态框以及更新进度相关代码:
[javascript] view plain copy
  1. // 弹出窗提示程序正在运行  
  2.                 setProgress("progressId""0%");  
  3.                   
  4.                 // 开启进度条模态框  
  5.                 openModal("myModal1");  
  6.                   
  7.                 // 定时请求任务进度  
  8.                 t=setTimeout("queryTaskProgress('"+ret+"')",1000);  

相关函数:
[javascript] view plain copy
  1. /** 
  2.  * 设置进度条 
  3.  * @param id 
  4.  * @param value 
  5.  */  
  6. function setProgress(id,value){  
  7.     $("#"+id).css("width",value);  
  8.     $("#"+id).html(value);  
  9. }  
  10.   
  11. /** 
  12.  * 开启模态框 
  13.  * @param id 
  14.  */  
  15. function openModal(id){  
  16.     $('#'+id).on('show.bs.modal'function(){  
  17.         var $this = $(this);  
  18.         var $modal_dialog = $this.find('.modal-dialog');  
  19.         // 关键代码,如没将modal设置为 block,则$modala_dialog.height() 为零  
  20.         $this.css('display''block');  
  21.         $modal_dialog.css({'margin-top': Math.max(0, ($(window).height() - $modal_dialog.height()) / 2) });  
  22.    });  
  23.     $('#'+id).modal({backdrop: 'static', keyboard: false});  
  24. }  

定时函数,查看进度:
[javascript] view plain copy
  1. /** 
  2.      * 请求任务进度 
  3.      */  
  4. function queryTaskProgress(appId){  
  5.     // ajax 发送请求获取任务运行状态,如果返回运行失败或成功则关闭弹框  
  6.     $.ajax({  
  7.         type : "POST",  
  8.         url : "Monitor",  
  9. //          dataType : "json",  
  10.         async:false,// 同步执行  
  11.         data:{APPID:appId},  
  12.         success : function(data) {  
  13. //          console.info("success:"+data);  
  14.             if(data.indexOf("%")==-1){// 不包含 ,任务运行完成(失败或成功)  
  15.                 clearTimeout(t);// 关闭计时器  
  16.                 // 关闭弹窗进度条  
  17.                 $('#myModal1').modal("hide");  
  18.                 // 开启提示条模态框  
  19.               
  20.                 $('#tipId').html(data=="FINISHED"?"模型训练完成!":   
  21.                     (data=="FAILED"?"调用建模失败!":"模型训练被杀死!"));  
  22.                   
  23.                 openModal("myModal2");  
  24.                 console.info("closed!");  
  25.                 return ;  
  26.             }  
  27.               
  28.             setProgress("progressId", data);  
  29.             // 进度查询每次间隔1500ms  
  30.             t=setTimeout("queryTaskProgress('"+appId+"')",1500);  
  31.         },  
  32.         error: function(data){  
  33.             console.info("error"+data);  
  34.               
  35.         }  
  36.     });  
  37. }  

3.2进度条模态框效果




3.2 Eclipse提交Spark任务到YARN后台实现

提交任务参考了部分Spark源码实现,下面是代码:

1. 封装Spark ALS算法程序,准备提交任务到YARN;
[java] view plain copy
  1. String[] runArgs=new String[]{  
  2.                 "--name","ALS Model Train ",  
  3.                 "--class","als.ALSModelTrainer",  
  4.                 "--driver-memory","512m",  
  5.                 "--num-executors""2",  
  6.                 "--executor-memory""512m",  
  7.                 "--jar","hdfs://master:8020/user/root/Spark141-als.jar",//  
  8.                 "--files","hdfs://master:8020/user/root/yarn-site.xml",  
  9.                 "--arg",input,  
  10.                 "--arg",output,  
  11.                 "--arg",train_percent,  
  12.                 "--arg",ranks,  
  13.                 "--arg",lambda,  
  14.                 "--arg",iteration  
  15.         };  
  16.         FileSystem.get(Utils.getConf()).delete(new Path(output), true);  
  17.         return Utils.runSpark(runArgs);  
(注意:1. 这里的部分参数应该是需要隔离到配置文件里面的,比如--class 或--driver-memory的值等;2. 本来在allAppStatus中设置的是一个全局变量,所以我本意是可以多用户提交任务,进而监控也是分开的,但是这里会有个问题,就是模型的输出目录,这个应该是需要和用户挂钩,同时在建模的时候,每个用户的推荐也需要采用各自对应的模型,但是目前来说,这个功能有点复杂,暂时就考虑一个用户,一个模型;)
2. 提交Spark任务到YARN,同时开启对应监控,更新任务状态
[java] view plain copy
  1. /** 
  2.      * 调用Spark 加入监控模块 
  3.      *  
  4.      * @param args 
  5.      * @return Application ID字符串 
  6.      */  
  7.     public static String runSpark(String[] args) {  
  8.         try {  
  9.             System.setProperty("SPARK_YARN_MODE""true");  
  10.             SparkConf sparkConf = new SparkConf();  
  11.             sparkConf.set("spark.yarn.jar""hdfs://master:8020/user/root/spark-assembly-1.4.1-hadoop2.6.0.jar");  
  12.             sparkConf.set("spark.yarn.scheduler.heartbeat.interval-ms""1000");  
  13.   
  14.             ClientArguments cArgs = new ClientArguments(args, sparkConf);  
  15.   
  16.             Client client = new Client(cArgs, getConf(), sparkConf);  
  17.             // client.run(); // 去掉此种调用方式,改为有监控的调用方式  
  18.   
  19.             /** 
  20.              * 调用Spark ,含有监控 
  21.              */  
  22.             ApplicationId appId = null;  
  23.             try{  
  24.                 appId = client.submitApplication();  
  25.             }catch(Throwable e){  
  26.                 e.printStackTrace();  
  27.                 //  返回null  
  28.                 return null;  
  29.             }  
  30.             // 开启监控线程  
  31.             updateAppStatus(appId.toString(),"2%" );// 提交任务完成,返回2%  
  32.             log.info(allAppStatus.toString());  
  33.             new Thread(new MonitorThread(appId,client)).start();  
  34.             return appId.toString();  
  35.         } catch (Exception e) {  
  36.             e.printStackTrace();  
  37.             return null;  
  38.         }  
  39.     }  

之前直接使用Client的run方法,提交任务,但是这样就获取不到applicationId,如下图
所以就去掉这种方式,参考Client中的run方法的具体实现,编写对应代码来进行任务提交;(需要注意这种提交方式,当任务失败或完成后,需要删除相关临时文件);
后台监控:
相关代码,在更新任务状态时进行:
[java] view plain copy
  1. // 完成/ 失败/杀死  
  2.             if (state == YarnApplicationState.FINISHED || state == YarnApplicationState.FAILED  
  3.                     || state == YarnApplicationState.KILLED) {  
  4.                 Utils.cleanupStagingDir(appId);  
  5.                 // return (state, report.getFinalApplicationStatus);  
  6.                 //  更新 app状态  
  7.                 log.info("Thread:"+Thread.currentThread().getName()+  
  8.                         appId.toString()+"完成,任务状态是:"+state.name());  
  9.                 Utils.updateAppStatus(appId.toString(), state.name());  
  10.                 return;  
  11.             }  
该代码在MonitorThread中;(但是,需要注意的是,如果Spark任务正在运行,那么这时关闭Tomcat,就会导致相关临时文件删除不了,为什么?请大家自己思考)

4. 推荐前台与后台功能实现

4.1 推荐页面前台

前台界面如下:


前台有两个功能,一个功能是输入用户ID,查询出当前用户ID评分过的电影信息;一个功能是根据用户ID和推荐个数,对用户进行电影推荐;
查询功能结果:



这里需要注意,评分全部为零,这个是因为在userRatedMovieIds这个变量中存储的只是用户的评分过的电影ID,并没有附加评分,所以可以在这个地方进行修改,以显示正确的电影评分(同时,这里的查询,也可以把所有信息存储在HBase中,进行查询);
推荐功能结果:

推荐功能展示的结果,是按照推荐分降序排列的;
不管是查询还是推荐,前台直接使用一个div来接收这些信息:
[html] view plain copy
  1. <div class="col-md-10 col-md-offset-1" id="movieResultId">  
接着使用AJax获取后台对应的数据进行拼接,在赋值给div:
[javascript] view plain copy
  1. // 绑定推荐button  
  2.     $("#recommendId").click(function(){  
  3.         var userId = $('#userId').val();  
  4.         var recommendNum = $('#recommendNumId').val();  
  5.         var ret =null;  
  6.         $.ajax({  
  7.             type : "POST",  
  8.             url : "Recommend",  
  9.             async:false,// 同步执行  
  10.             data : {userId:userId,flag:"recommend",recommendNum:recommendNum},  
  11. //          dataType : "json",  
  12.             success : function(data) {// data 返回appId  
  13.                 ret = data;  
  14.             },  
  15.             error: function(data){  
  16.                 console.info("error"+data);  
  17.                 ret = data=="null"?"null":data;  
  18.             }  
  19.         });  
  20.           
  21.           
  22.         var showResultHtml = '<br>'+  
  23.                             '<p>数据如下:</p>'+  
  24.                             '<div class="table-responsive">' +  
  25.                                 '<table class="table table-striped">' +  
  26.                                     '<thead>'+  
  27.                                         '<tr>'+  
  28.                                             '<th>MovieId</th>'+  
  29.                                             '<th>电影名</th>'+  
  30.                                             '<th>标签</th>'+  
  31.                                             '<th>推荐分</th>'+  
  32.                                         '</tr>'+  
  33.                                     '</thead>'+  
  34.                                     '<tbody>'+  
  35.                                     ret +   
  36.                                     '</tbody>'+  
  37.                                 '</table>'+  
  38.                             '</div>';  
  39.         $('#movieResultId').html(showResultHtml);  
  40.     });  


4.2 推荐页面后台

推荐页面的查询,只是简单的Map的数据获取而已;重点是推荐功能。
推荐功能最开始我想的是直接保存Spark ALS的模型,然后调用Spark ALS模型的predict(user,product),即可直接得到用户的推荐分,但是这样是不行的:
参考:http://stackoverflow.com/questions/34288435/using-java-for-running-mllib-model-with-streaming ;Spark里面的模型有些是本地的有些是分布式的,如果是分布式的,那么是不能执行类似predict操作的,而Spark ALS的模型MatrixFactorizationModel 是分布式的,所以不能够直接执行predict操作。这里同样是参考Spark的源码,来进行的。
在建模完成后,把Spark ALS模型的两个参数userFeatures、productFeatures分别存入HDFS,然后在模型推荐的时候把其加载进内存,使用userFeatures和productFeatures两个变量即可完成推荐:
[java] view plain copy
  1. /** 
  2.      * 预测 如果没有初始化,则进行初始化 
  3.      *  
  4.      * @param uid 
  5.      * @param recNum 
  6.      * @return 
  7.      * @throws NoSuchMethodException  
  8.      * @throws InvocationTargetException  
  9.      * @throws InstantiationException  
  10.      * @throws IllegalAccessException  
  11.      */  
  12.     public static List<Movie> predict(int uid,int recNum) throws IllegalAccessException, InstantiationException, InvocationTargetException, NoSuchMethodException {  
  13.         if (userFeatures.size() <= 0 || productFeatures.size() <= 0) {  
  14.             try {  
  15.                 userFeatures = getModelFeatures(userFeaturePath);  
  16.                 productFeatures = getModelFeatures(productFeaturePath);  
  17.             } catch (IOException e) {  
  18.                 return null;  
  19.             }  
  20.             if (userFeatures.size() <= 0 || productFeatures.size() <= 0) {  
  21.                 System.err.println("模型加载失败!");  
  22.                 return null;  
  23.             }  
  24.         }  
  25.   
  26.         // 使用模型进行预测  
  27.         // 1. 找到uid没有评价过的movieIds  
  28.         Set<Integer> candidates = Sets.difference((Set<Integer>) allMovieIds, userWithRatedMovies.get(uid));  
  29.   
  30.         // 2. 构造推荐排序堆栈  
  31.         FixSizePriorityQueue<Movie> recommend = new FixSizePriorityQueue<Movie>(recNum);  
  32.         Movie movie = null;  
  33.         double[] pFeature = null;  
  34.         double[] uFeature = userFeatures.get(uid);  
  35.         double score = 0.0;  
  36.         BLAS blas = BLAS.getInstance();  
  37.         for (int candidate : candidates) {  
  38.             movie = movies.get(candidate).deepCopy();  
  39.             pFeature = productFeatures.get(candidate);  
  40.             if (pFeature == null)  
  41.                 continue;  
  42.             score = blas.ddot(pFeature.length, uFeature, 1, pFeature, 1);  
  43.             movie.setRated((float) score);  
  44.             recommend.add(movie);  
  45.         }  
  46.   
  47.         return recommend.sortedList();  
  48.     }  

中间的score= blas.ddot就是计算推荐分的;

总结

1. 基本完成相关推荐系统功能;
2. 相关参数需要额外添加配置文件,而不是直接硬编码到代码中;
3. 推荐只能针对已经存在的用,不能进行匿名推荐(同时使用SPark ALS模型推荐的结果基本一样,这个是Spark的bug?还是调用哪里有问题?);
4. 添加多用户调用支持;
5. 查询用户评分过的功能完善(对应评分获取);





原创粉丝点击