前言
本篇毕设是基于b站up主——每天都要机器学习的开源项目,以该项目的深度学习部分为毕设的核心部分,再自己搭建起了前端页面以及与后端部分交互的接口逻辑而完成的毕设成果。各位同志们如果对我的前后端构建想要有一个更加深入的了解,可以参照本文讲解进一步理顺逻辑,一共分为三篇。第一篇为前端界面的构建,第二篇为后端接口的逻辑实现,第三部分为前后端交互功能的实现。最后,开源作者在b站上在深度学习部分有对模型的训练及其代码逻辑的详细讲解,这一部分就可以去看作者的视频啦。
由于已是半年再度回顾毕设,且第一次写下长篇的博客,博客展示的都是一个小部分的代码切片,或许有些地方比较跳跃或者难懂,各位可以结合一下整体源码,在全局中理解一下,再回来看看,是否能加深理解。同时博客中也有许多可能重要的地方没有被提及,而写下的内容也会存在讲解不清以及错误的地方,若有疏漏,若仍有疑惑,还请各位不吝赐教,提出问题,我都会悉数改进。
up主b站视频讲解:
我的毕设演示视频:
毕设演示|基于知识图谱实现简易医疗问答系统_哔哩哔哩_bilibili
我的毕设开源源码:(望各位同志们能够在参考的基础上开发出一些新的功能哦)
一、环境搭建
开源项目的深度学习环境是tensorflow1.14.0、keras2.2.5、cuda(找到和自己电脑相匹配的版本安装),版本一定要对应好,否则会有很多很多报错,具体的安装我参考的是如下博客Anaconda 安装后环境变量配置 超详细小白版_anaconda环境变量配置-优快云博客
blog.youkuaiyun.com/Ps_hello/article/details/131696828
Anaconda3、TensorFlow和keras简单安装方法(较详细)_keras安装-优快云博客
按照步骤验证安装成功后,我是在vscode中安装解释器,完成环境的配置。
运行开源项目中文件夹build_kg下的build_kg_utils.py完成知识图谱的创建,构建时间较长,需要耐心等待。
二、前端界面构建
使用vue完成整个界面的构建,包含(element-ui、axios、vuex、route、neo4j-driver、neovis.js等。最后两个包用来实现图谱的可视化功能)
项目目录结构
Login为登陆界面、ChatBot为导航栏中的对话系统界面、NeoVisual对应导航栏中的图谱可视化界面、BackPlat对应导航栏中的后台管理界面、MainBoard为导航栏的展示界面。
项目整体界面
前端部分实现的无非是与后端的联动,前端使用axios对后端提供的相应接口api进行请求,而后端将请求所需要的数据返回。我们在控制台中查看返回的结果,按照相应的层级结构将变量赋予给在vue的data中定义好的变量即可进行渲染展示。涉及到数据的请求都是按照以上的步骤来操作。
导航栏界面
1.由于我将导航栏设置在全局,也即点击相应的导航栏并不会改变导航栏的所在位置,因此需要将MainBoard界面的路由设置在最外面,里面嵌套三个子界面的路由,并在keep-alive标签中设置router-view路由。
<div>
<el-breadcrumb separator="|" class="bread">
<el-breadcrumb-item :to="{ path: '/' }" class="font" style="font-size:20px">
<span>医疗知识图谱智能问答系统</span>
</el-breadcrumb-item>
<el-breadcrumb-item class="font">
<router-link to="/main/chat">
<span @click="change(1)" :class="{'highlight':index===1}">对话系统</span>
</router-link>
</el-breadcrumb-item>
<el-breadcrumb-item class="font">
<router-link to="/main/neovisual">
<span @click="change(2)" :class="{'highlight':index===2}">图谱可视化</span>
</router-link>
</el-breadcrumb-item>
<el-breadcrumb-item class="font" >
<router-link to="/main/backplat">
<span @click="change(3)" :class="{'highlight':index===3}">后台管理</span>
</router-link>
</el-breadcrumb-item>
<span class="name">你好,{{username}}</span>
</el-breadcrumb>
<div v-if="isshow" class="welcome">{{ welcomeMessage }} </div>
<keep-alive>
<router-view></router-view>
</keep-alive>
</div>
2.是在登录界面跳转后,制作了“欢迎使用问答系统”字样,js逻辑如下。
computed: {
welcomeMessage() {
return this.welcomeChars.join(''); // join方法用于将数组(或一个类数组对象)的所有元素连接到一个字符串中,中间不是空格符
},
},
mounted() {
const message = "欢迎使用智能问答系统!";
let index = 0;
const interval = setInterval(() => {
if (index < message.length) {
this.welcomeChars.push(message[index]);
index++;
} else {
clearInterval(interval); // 当所有字符都添加完后,清除定时器 ,一定要删除,否则会消耗内存
}
}, 150);
},
聊天主界面
1.要实现动态的将对话放置到聊天界面中,那么就要定义一个数组messages,里面定义两个变量,一个为text,用于存储文本,另一个为sender,该属性用于实现机器人和用户的样式转换,若当前值为robot,则样式class动态绑定robot;若为man,则绑定用户样式。每输入一个问题时,就会被压入messages.text中,v-for循环中每次都会重新渲染dom,因此每次都能将新获得的消息传入展示渲染到聊天界面上。
<div class="outer">
<div class="header">
<div class="header-title">
<i class="el-icon-caret-left" title="返回" v-show="isshow" @click="backend"></i>
<i class="el-icon-search" title="实体识别" @click="multishow=!multishow" v-show="!isshow"></i>
<p class="title">医疗智能问答系统</p>
<i class="el-icon-message" @click="shownews" title="消息">
<div class="newscircle" v-show="newsnum">
<span>{{newsnum}}</span>
</div>
</i>
</div>
</div>
<div class="show1" v-show="!isshow">
<div class="main" ref="dialogueContainer">
<div class="screen-inner">
<!--v-for每次都会渲染一次dom中的内容-->
<div v-for="(message,index) in messages" :key="index" :class="message.sender==='robot'?'robot-dialogue':'man-dialogue'">
<!-- 若动态进行绑定src,则需要将引入的图片放到data中,然后再引用变量 -->
<img :src="message.sender==='robot' ? robotImg : manImg"
:class="message.sender==='robot'?'robotinfo':'userinfo'">
<div :class="message.sender==='robot'?'dialogue-text':'dialogue-input'">{{message.text}}</div>
<i class="el-icon-chat-dot-square" title="评价" v-if="message.sender==='robot'" @click="comment(message)"></i>
</div>
</div>
</div>
</div>
<div class="submit">
<textarea
id="dialogue-input"
@keydown.enter="submit"
@keydown.enter.prevent
v-model="notedata"
placeholder="请输入您的问题,按Enter键提交">
</textarea>
</div>
</div>
data(){
return{
messages:[
{text:'你好,欢迎使用医疗自助问答服务系统,你可以对疾病从定义、病因、预防、临床表现、相关病症、治疗方法、所属科室、治愈率、禁忌、治疗时间等方面向我提问,祝您身体健康!',
sender:'robot'}
],
}
},
submit(){
const text = this.notedata.replace(/\n/g, "")//将最后的回车空格去掉
const requestData = {sent:text}
this.$axios.get('http://127.0.0.1:5000/index',{
params:requestData
})
.then((res)=>{
console.log(res);
this.messages.push({text:this.notedata,sender:'man'})
//这个if是用来控制右上角动画的展示,讲述下一个功能将会用到
if(!(res.data.diseasename instanceof Array)){
this.diseasename = res.data.diseasename
this.updatenum(this.diseasename)
// diseasename用来控制实体识别结果动画的消失,将该效果持续三秒
setTimeout(()=>{
this.diseasename = ''
},3000)
}
//间隔1s后再将后端返回的答案加入到messages中
setTimeout(()=>{
this.messages.push({text:res.data.reply,sender:'robot'})
},1000)
this.notedata=''
})
}
robot和man的样式一致,只是将位置置于了相反的方向,此处不再列举展开。
.show1{
.main{
width: 800px;
height: 410px;
max-height: 410px;
overflow-y:auto;
.screen-inner{
margin: 15px;
.robot-dialogue{
width: 100%;//宽度一定要设置成100%,确保其拥有父元素的整个宽度
// 添加 overflow: hidden; 主要是为了清除浮动(clear float)。在这种情况下,由于子元素使用了浮动,父元素不会自动扩展以包含浮动元素,可能导致布局问题。通过为父元素设置 overflow: hidden;,可以强制父元素包含其浮动子元素。
// 它的工作原理是,设置 overflow: hidden; 的元素会创建一个 BFC(块级格式化上下文),BFC 会包含浮动元素并防止其溢出到父元素之外,从而解决了浮动元素导致的布局问题。
overflow:hidden;
margin-top: 15px;
// 图片圆框样式
.robotinfo{
width: 35px;
height: 35px;
float: left;
margin-right: 10px;
border-radius: 15px;
}
.dialogue-text{
max-width: 665px;//需要设置出最大长度,以避免文本太长将文本框撑开导致文本框样式不一致
background-color: #fff;
float: left;
padding:10px;
font-size: 14px;
position:relative;
}
// 每个聊天框后面的小图标
.el-icon-chat-dot-square{
cursor: pointer;
margin-left:5px;
line-height: 35px;
}
// 在每个文本框前面添加一个小三角形样式
.dialogue-text::before{
position:absolute;
left: -8px;
content: '';
border-right: 10px solid #FFF;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
}
}
2.右上角每次随着用户信息的输入而弹出的实体识别结果对话框的效果是使用了vue中的动画,这里定义了diseasename变量,它不仅是展示的内容,同时也是控制动画效果进出的一个关键变量。对diseasename修改的方法仍然是在submit中,一旦提交,就获取相应的res变量进行相应的修改。
<!--每次实体识别后右上角会有一个同步的展示效果,这里用的是动画-->
<transition name="rec-on-right">
<!--根据v-if来控制动画框是否展示-->
<div class="information" v-if="diseasename">
<div class="firstline">
<i class="el-icon-info"></i>
<h4>实体识别结果</h4>
</div>
<span>{{diseasename}}</span>
</div>
</transition>
注意,我们需要在transition中定义好动画的名字,然后在css样式中在这个名字后面加上enter-active和leave-active,在其中的animation引入keyframes关键帧。
<style>
.show2{
width: 800px;
height: 545px;
background-color: white;
max-width: 800px;
max-height: 545px;
overflow-y:auto;
.outer-box-card{
border-bottom: 5px solid;
.box-card:hover{
background-color: rgb(184, 182, 182);
}
}
}
@keyframes slideInFromRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideOutToLeft {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
.rec-on-right-enter-active {
animation: slideInFromRight 1s ease;
}
.rec-on-right-leave-active {
animation: slideOutToLeft 1s ease;
}
</style>
3.左边的实体识别和意图识别功能界面代码如下,其出入效果也由动画实现,并且需要调用后端接口获取相应的识别数据,其在js中定义的方法为getword(),在毕设记录(三)中会详细讲解。
部分样式在标签中给出,部分样式在style中给出,整个框的滑入滑出是通过动画实现的。
<transition name="rec-in-left">
<div class="recognize" v-show="multishow">
<div class="title">识别检测功能</div>
<div class="searchbox">
<div style="margin-top:10px;font-weight:bold">请在下面的文本框输入问句</div>
<textarea cols="40" rows="10" style="resize:none;" v-model="multiword"></textarea>
<el-button type="primary" @click="getword(0)">实体识别</el-button>
<el-button type="primary" @click="getword(1)">意图识别</el-button>
</div>
<div class="answer">
<div style="font-weight:bold">识别结果</div>
<div class="realword" ref="showanswer" style="width:300px;height:150px;border:2px solid black;margin-left:15px;"></div>
<el-button type="danger" style="margin-top:5px;" @click="multishow=!multishow">退出</el-button>
</div>
</div>
</transition>
.recognize{
width: 340px;
height: 600px;
float: left;
border: 2px solid black;
.title{
border: 2px solid black;
text-align: center;
font-weight: bold;
height: 50px;
line-height: 50px;
margin-bottom:15px 0;
background-color: #d6d6d6;
}
.searchbox{
margin-top: 20px;
height: 240px;
text-align: center;
border: 2px solid black;
}
.answer{
margin-top: 40px;
text-align: center;
height: 240px;
border: 2px solid black;
}
}
@keyframes slideOutFromLeft{
from {
transform: translateX(-100%)
}
to{
transform: translateX(0)
}
}
@keyframes slideOutToRight{
from {
transform: translateX(0)
}
to{
transform:translateX(-100%)
}
}
.rec-in-left-enter-active{
animation:slideOutFromLeft 0.5s ease
}
.rec-in-left-leave-active{
animation:slideOutToRight 0.5s ease
}
图谱可视化界面
获取数据实际上是利用输入的数据,将其组建成cypher语句。cypher语句的写法有很多,看个人需要实现哪类数据查询,可以去学习一下其它类型的cypher 查询语句,本项目只是用了最简单的语句进行展示。
左边为图谱直接可视化,右图为图数据库转换成关系型数据库可视化,上方有相应的下拉选项框,其背后的逻辑是为了传递相应的值实现cypher语句的查询。
value、value1和value2各对应选项框中的一个具体内容的一个映射,可以查看源代码看到详细的标识。没有想到更好的写法,因此写成了三个if。每个if里面的p、r、q中箭头所指方向是不同的,以此来匹配neo4j数据库中的查询。大家可以研究一下neo4j中的不同图谱对应的查询语句,然后可以在前端中展示更细致的图谱查询结果哦。
<div class="header">
<el-form :model="formInline" >
<el-row class="demo-form-inline">
<el-form-item>
<el-input v-model="formInline.input" placeholder="请输入疾病名称" style="width:240px;"></el-input>
<el-select v-model="value2" placeholder="请选择实体类别" style="margin-left:5px;">
<el-option
v-for="(item,index) in options3"
:key="index"
:label="item.label"
:value="item.value2">
</el-option>
</el-select>
<el-select v-model="value" placeholder="请选择查询关系" style="margin-left:5px">
<el-option
v-for="(item,index) in options1"
:key="index"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
<el-select v-model="value1" placeholder="请选择关联关系" style="margin-left:5px">
<el-option
v-for="(item,index) in options2"
:key="index"
:label="item.label"
:value="item.value1">
</el-option>
</el-select>
</el-form-item>
<el-form-item class="btn">
<el-button :disabled="isClicked" type="primary" icon="el-icon-search" @click="submit">搜索</el-button>
</el-form-item>
</el-row>
</el-form>
</div>
methods:{
submit () {
if(this.value===0){
if(this.value1===0){
// this.cypher = `MATCH(p:疾病)-[r]-(q) WHERE p.name='${this.formInline.input}' RETURN p,r,q`
this.cypher = `MATCH(p:${this.value2})-[r]-(q) WHERE p.name='${this.formInline.input}' RETURN p,r,q`
}
else if(this.value1===1){
this.cypher = `MATCH(p:${this.value2})-[r]->(q) WHERE p.name='${this.formInline.input}' RETURN p,r,q`
}
else if(this.value1===2){
this.cypher = `MATCH(p:${this.value2})<-[r]-(q) WHERE p.name='${this.formInline.input}' RETURN p,r,q`
}
}
else {
if(this.value1===0){
this.cypher = `MATCH(p:${this.value2})-[r:${this.value}]-(q) WHERE p.name='${this.formInline.input}' RETURN p,r,q`
}
else if(this.value1===1){
this.cypher = `MATCH(p:${this.value2})-[r:${this.value}]->(q) WHERE p.name='${this.formInline.input}' RETURN p,r,q`
}
else if(this.value1===2){
this.cypher = `MATCH(p:${this.value2})<-[r:${this.value}]-(q) WHERE p.name='${this.formInline.input}' RETURN p,r,q`
}
}
this.search()
.then((length) => {
if(length){
this.viz.renderWithCypher(this.cypher);
}
else {
this.$message.error("当前疾病不存在该关系,请重新选择!");
this.tableData=[]
}
})
},
}
图谱直接可视化配置
这里主要展示js部分的配置,网上的参考资料较少,踩了一点坑,因为当时我参考的博客已经和现在neo4j的版本对应不上了,导致图谱中无法展现出正确的文字,相关的配置得按照我现在这样来写,才能正确的显示图谱中的文字。
在html部分,需要自行定义一个div容器:
<div class="myDiv">
<div id="viz" ref="viz"></div>
</div>
要注意在script中引入两个包,最后进行如下配置
<script>
import NeoVis from 'neovis.js';//图谱可视化的包引入
import neo4j from 'neo4j-driver'//图谱转换为关系型数据库包引入
data(){
return{
viz:{}//定义一个viz对象
}
},
methods:{
draw(){
var config ={
containerId: 'viz',
neo4j: {
serverUrl: 'bolt://localhost:7687',
serverUser: 'neo4j',
serverPassword: '自己的neo4j密码'
},
labels: {
科室:{
label: 'name', // 节点显示的文字对应内容key
},
检查:{label: 'name'},
疾病:{label: 'name'},
症状:{label: 'name'},
药企:{label: 'name'},
药品:{label: 'name'},
菜谱:{label: 'name'},
食物:{label: 'name'},
},
relationships: {
belongs_to: {label: "name"},
acompany_with: {label: "name"},
cure_department:{label: "name"},
do_eat:{label: "name"},
has_common_drug:{label: "name"},
has_symptom:{label: "name"},
need_check:{label: "name"},
not_eat:{label: "name"},
production:{label: "name"},
recommand_drug:{label: "name"},
recommand_recipes:{label: "name"},
},
visConfig: {
edges: {
arrows: {
to: {enabled: true}
}
}
},
initialCypher: "MATCH (p)-[r]->(m) RETURN p, r, m limit 50"
}
this.viz = new NeoVis(config)
// console.log(this.viz);
this.viz.render()
// 点击完搜索全图之后 才能开启搜索功能,因为需要先渲染一下
this.isClicked = false
},
//在点进这个页面之前,首先会加载一次,使其初始化显示出来
mounted(){
this.draw()
}
</script>
图谱转换为关系型数据展示js配置
methods:{
search(){
return new Promise((resolve,reject)=>{
this.tableData = []
const driver = neo4j.driver("bolt://localhost:7687",neo4j.auth.basic("neo4j","自己的密码"))
const session = driver.session()
session.run(this.cypher)
.then((res)=>{
console.log(res);
this.tableData = []
if(this.value1===0 || this.value1===1){
for(let i=0;i<res.records.length;i++){
this.tableData.push
({
//此处的层级结构在控制台中查看返回数据的层级结构可以获得
diseasename:res.records[i]._fields[0].properties.name,
relationship:this.englishToChinese[res.records[i]._fields[1].properties.name],
node:res.records[i]._fields[2].properties.name
})
}
}
// 成功时将长度值返回,使用resolve即可返回
resolve(this.tableData.length)
})
})
},
handleCurrent(val){
this.currentPage = val
}
}
后台管理界面
这一部分做了三个小界面,分别是处理用户评价界面、疾病实体可视化统计界面以及用户管理界面。这里仅展示数据可视化界面,剩余两个界面可查看源码,写法比较固定。
该可视化界面制作了饼图、柱状图和词云,效果如下:
首先是使用el-table展示用户提问疾病的数据,在页面渲染阶段使用dom将数据记载出来即可。接着使用makeform和makeciyun分别来生成两种统计表。传递进去的参数为index,因两种类型的统计图共用一个页面,因此要用index来区分。
<el-tab-pane label="疾病提问统计" name="second" class="second">
<el-table :data="diseasedata" style="width:300px" class="tablelist">
<el-table-column prop="id" label="序号" width="100" align="center"></el-table-column>
<el-table-column prop="diseasename" label="疾病名称" width="100" align="center"></el-table-column>
<el-table-column prop="num" label="提问次数" width="100" align="center"></el-table-column>
</el-table>
<el-button type="primary" @click="makeform(1)">生成统计图表</el-button>
<el-button type="primary" @click="makeciyun(2)">生成词云</el-button>
<div class="outer">
<div class="chart" v-show="index===1">
<div class="echart" id="echart"></div>
<div class="circle" id="circle"></div>
</div>
<div class="cloud" id="cloud" v-show="index===2"></div>
</div>
</el-tab-pane>
注意在loadnum中要向后端的数据库中请求提问的数据,分别填充到柱状图需要的数据——xData和yData以及pieData
<script>
import * as echarts from "echarts";
import 'echarts-wordcloud'
data(){
return{
xData:[],
yData:[],
pieData:[],
}
},
methods:{
// 加载数据库中的第二个表到第二个table中
loadnum(){
this.$axios.get('http://127.0.0.1:5002/getnum')
.then((res)=>{
for(let i=0;i<res.data.length;i++){
this.diseasedata.push(
{
id:res.data[i].id,
diseasename:res.data[i].diseasename,
num:res.data[i].num
}
)
this.xData.push(res.data[i].diseasename)
this.yData.push(res.data[i].num)
this.pieData.push(
{
value:res.data[i].num,
name:res.data[i].diseasename
}
)
}
})
},
mounted(){
this.loadnum()
}
}
</script>
饼图和柱状图的配置,详细的参数配置可见其余文档,大体意思可以看英文名,这里就不再赘述啦。
initEcharts(){
const option ={
xAxis:{
type:'category',
data:this.xData,
axisLabel:{
show:true,
formatter:function(value){
return value.split("").join("\n");
}
}
},
yAxis:{
type:'value'
},
series:[
{
type:"bar",
data:this.yData,
label:{
show:true,
position:'top'
},
barWidth: 20,
// barGap:'80%',
// barCategoryGap:'50%',
showBackground: true,
// 将背景完整的补充上
backgroundStyle: {
color: 'rgba(180, 180, 180, 0.2)'
},
itemStyle:{
normal:{
color:function(){return "#"+Math.floor(Math.random()*(256*256*256-1)).toString(16);}
}
}
}
]
};
const myChart = echarts.init(document.getElementById("echart"))
myChart.setOption(option)
},
initpieEcharts(){
const option ={
legend:{
data:this.xData,
right:"10%",
// top:"30%",
// orient:"vertical"
},
series:[
{
type:"pie",
label:{
show:true,
formatter:"{b}:{c}({d}%)"
},
data:this.pieData
}
]
}
const mycircle = echarts.init(document.getElementById("circle"))
mycircle.setOption(option)
},
注意,配置词云的数据必须是一个数组对象中包含着名称和该名称出现的频次,这里继续使用pieData,刚好也是词云要用到的数据,词云配置时会自动读取该数据对象中的两个数据。
initciyuncharts(){
const option={
series:[
{
type:'wordCloud',
//配置数据,一定要是一个数组对象,否则将无法正确配置,一开始只是传递了一个对象
data:this.pieData,
gridSize: 20,//用来调整词之间的距离
sizeRange: [14, 60],//用来调整字的大小范围
rotationRange: [-90,90],//设置词的旋转角度
fontSizeRange: [5, 40],//设置字体大小
textStyle:{
normal:{
color: function() {
return 'rgb(' + [
Math.round(Math.random() * 160),
Math.round(Math.random() * 160),
Math.round(Math.random() * 160)
].join(',') + ')';
}
}
}
}
]
};
const cloudmap = echarts.init(document.getElementById('cloud'))
cloudmap.setOption(option)
},